@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
package/backend/index.ts CHANGED
@@ -156,7 +156,12 @@ startServer().catch((error) => {
156
156
  });
157
157
 
158
158
  // Graceful shutdown - properly close server and database
159
+ let isShuttingDown = false;
160
+
159
161
  async function gracefulShutdown() {
162
+ if (isShuttingDown) return;
163
+ isShuttingDown = true;
164
+
160
165
  console.log('\nšŸ›‘ Shutting down server...');
161
166
  try {
162
167
  // Close MCP remote server (before engines, as they may still reference it)
@@ -177,6 +182,13 @@ async function gracefulShutdown() {
177
182
  process.on('SIGINT', gracefulShutdown);
178
183
  process.on('SIGTERM', gracefulShutdown);
179
184
 
185
+ // Ignore SIGHUP — sent when the controlling terminal closes or an SSH session
186
+ // disconnects. Without a handler Bun exits immediately; we want the server to
187
+ // keep running (e.g. started in a background tab or remote shell).
188
+ process.on('SIGHUP', () => {
189
+ debug.log('server', 'Received SIGHUP — ignoring (server stays running)');
190
+ });
191
+
180
192
  // Safety net: prevent server crash from unhandled errors.
181
193
  // These can occur when AI engine SDKs emit asynchronous errors that bypass
182
194
  // the normal try/catch flow (e.g., subprocess killed during initialization).
@@ -1,22 +1,74 @@
1
1
  import { EventEmitter } from 'events';
2
- import type { Page, HTTPRequest, Frame } from 'puppeteer';
2
+ import type { Page, HTTPRequest, Frame, CDPSession } from 'puppeteer';
3
3
  import type { BrowserTab } from './types';
4
+ import { debug } from '$shared/utils/logger';
4
5
 
5
6
  export class BrowserNavigationTracker extends EventEmitter {
7
+ private cdpSessions = new Map<string, CDPSession>();
8
+
6
9
  constructor() {
7
10
  super();
8
11
  }
9
12
 
13
+ /**
14
+ * Check if two URLs differ only by hash/fragment.
15
+ * Hash-only changes are same-document navigations and should NOT trigger
16
+ * full page reload or streaming restart.
17
+ */
18
+ private isHashOnlyChange(oldUrl: string, newUrl: string): boolean {
19
+ try {
20
+ const oldParsed = new URL(oldUrl);
21
+ const newParsed = new URL(newUrl);
22
+ // Compare URLs without hash — if identical, it's a hash-only change
23
+ oldParsed.hash = '';
24
+ newParsed.hash = '';
25
+ return oldParsed.href === newParsed.href;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Check if two URLs share the same origin (protocol + host + port).
33
+ * Same-origin navigations are likely SPA internal navigations and should
34
+ * NOT show a progress bar — the streaming restart happens silently while
35
+ * the last rendered frame stays visible.
36
+ */
37
+ private isSameOrigin(oldUrl: string, newUrl: string): boolean {
38
+ try {
39
+ return new URL(oldUrl).origin === new URL(newUrl).origin;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
10
45
  async setupNavigationTracking(sessionId: string, page: Page, session: BrowserTab) {
11
46
 
12
- // Track navigation start (loading begins)
47
+ // Track navigation start (loading begins) — only for cross-origin document navigations
13
48
  page.on('request', (request: HTTPRequest) => {
14
49
  // Only track main frame document requests (not resources like images, CSS, etc.)
15
50
  // Puppeteer uses resourceType() instead of isNavigationRequest()
16
51
  if (request.resourceType() === 'document' && request.frame() === page.mainFrame()) {
17
52
  const targetUrl = request.url();
18
53
 
19
- // Emit navigation loading event to frontend
54
+ // Skip hash-only changes — they are same-document navigations
55
+ // that don't need loading states or streaming restart
56
+ if (this.isHashOnlyChange(session.url, targetUrl)) {
57
+ debug.log('preview', `ā­ļø Skipping navigation-loading for hash-only change: ${session.url} → ${targetUrl}`);
58
+ return;
59
+ }
60
+
61
+ // Skip same-origin navigations — they are likely SPA internal navigations.
62
+ // No progress bar is shown; the last rendered frame stays visible while
63
+ // streaming restarts silently in the background. This makes SPA navigation
64
+ // feel instant, similar to how a real browser shows the old page until
65
+ // the new one is ready.
66
+ if (this.isSameOrigin(session.url, targetUrl)) {
67
+ debug.log('preview', `ā­ļø Skipping navigation-loading for same-origin navigation: ${session.url} → ${targetUrl}`);
68
+ return;
69
+ }
70
+
71
+ // Emit navigation loading event to frontend (cross-origin navigations only)
20
72
  this.emit('navigation-loading', {
21
73
  sessionId,
22
74
  type: 'navigation-loading',
@@ -26,12 +78,59 @@ export class BrowserNavigationTracker extends EventEmitter {
26
78
  }
27
79
  });
28
80
 
29
- // Track all navigation events - including redirects, link clicks, and hash changes
30
- page.on('framenavigated', (frame: Frame) => {
81
+ // Track full page navigations (actual page loads, not SPA)
82
+ page.on('framenavigated', async (frame: Frame) => {
31
83
  // Only track main frame navigation (not iframes)
32
84
  if (frame === page.mainFrame()) {
33
85
  const newUrl = frame.url();
34
86
 
87
+ // Skip internal Chrome error/system pages — they indicate a failed navigation
88
+ // and should not be surfaced to the frontend as a real URL change.
89
+ if (newUrl.startsWith('chrome-error://') || newUrl.startsWith('chrome://')) return;
90
+
91
+ // Skip if URL hasn't changed (already handled by navigatedWithinDocument)
92
+ if (newUrl === session.url) return;
93
+
94
+ // Hash-only changes should be treated as SPA navigations
95
+ // (no streaming restart needed, page context is unchanged)
96
+ if (this.isHashOnlyChange(session.url, newUrl)) {
97
+ debug.log('preview', `šŸ”„ Hash-only change detected, treating as SPA navigation: ${session.url} → ${newUrl}`);
98
+ session.url = newUrl;
99
+ this.emit('navigation-spa', {
100
+ sessionId,
101
+ type: 'navigation-spa',
102
+ url: newUrl,
103
+ timestamp: Date.now()
104
+ });
105
+ return;
106
+ }
107
+
108
+ // Same-origin navigation: check if the video encoder script survived.
109
+ // SPA frameworks (SvelteKit, Next.js, etc.) often trigger framenavigated
110
+ // for client-side routing even though the page context is NOT replaced.
111
+ // If __webCodecsPeer still exists, the scripts are alive → SPA navigation.
112
+ // If it's gone, the page was truly replaced → full navigation + stream restart.
113
+ if (this.isSameOrigin(session.url, newUrl)) {
114
+ try {
115
+ const scriptAlive = await page.evaluate(() => !!(window as any).__webCodecsPeer);
116
+ if (scriptAlive) {
117
+ debug.log('preview', `šŸ”„ Same-origin navigation with script alive (SPA): ${session.url} → ${newUrl}`);
118
+ session.url = newUrl;
119
+ this.emit('navigation-spa', {
120
+ sessionId,
121
+ type: 'navigation-spa',
122
+ url: newUrl,
123
+ timestamp: Date.now()
124
+ });
125
+ return;
126
+ }
127
+ debug.log('preview', `šŸ“„ Same-origin navigation with script dead (full reload): ${session.url} → ${newUrl}`);
128
+ } catch {
129
+ // page.evaluate failed — page context was replaced, fall through to full navigation
130
+ debug.log('preview', `šŸ“„ Same-origin navigation evaluate failed (full reload): ${session.url} → ${newUrl}`);
131
+ }
132
+ }
133
+
35
134
  // Update session URL
36
135
  session.url = newUrl;
37
136
 
@@ -48,10 +147,43 @@ export class BrowserNavigationTracker extends EventEmitter {
48
147
  // Also track URL changes via JavaScript (for single page applications)
49
148
  page.on('load', async () => {
50
149
  const currentUrl = page.url();
150
+ // Skip internal Chrome error/system pages
151
+ if (currentUrl.startsWith('chrome-error://') || currentUrl.startsWith('chrome://')) return;
51
152
  if (currentUrl !== session.url) {
52
-
153
+
154
+ // Hash-only changes on load — treat as SPA navigation
155
+ if (this.isHashOnlyChange(session.url, currentUrl)) {
156
+ session.url = currentUrl;
157
+ this.emit('navigation-spa', {
158
+ sessionId,
159
+ type: 'navigation-spa',
160
+ url: currentUrl,
161
+ timestamp: Date.now()
162
+ });
163
+ return;
164
+ }
165
+
166
+ // Same-origin: check if video encoder script survived
167
+ if (this.isSameOrigin(session.url, currentUrl)) {
168
+ try {
169
+ const scriptAlive = await page.evaluate(() => !!(window as any).__webCodecsPeer);
170
+ if (scriptAlive) {
171
+ session.url = currentUrl;
172
+ this.emit('navigation-spa', {
173
+ sessionId,
174
+ type: 'navigation-spa',
175
+ url: currentUrl,
176
+ timestamp: Date.now()
177
+ });
178
+ return;
179
+ }
180
+ } catch {
181
+ // Fall through to full navigation
182
+ }
183
+ }
184
+
53
185
  session.url = currentUrl;
54
-
186
+
55
187
  this.emit('navigation', {
56
188
  sessionId,
57
189
  type: 'navigation',
@@ -61,34 +193,59 @@ export class BrowserNavigationTracker extends EventEmitter {
61
193
  }
62
194
  });
63
195
 
64
- // Track hash changes (fragment identifier changes like #contact-us)
65
- // Temporarily disabled URL tracking injection to test CloudFlare evasion
66
- /*
67
- await page.evaluateOnNewDocument(() => {
68
- let lastUrl = window.location.href;
196
+ // Track SPA navigations (pushState/replaceState) via CDP
197
+ // Uses Page.navigatedWithinDocument which fires for same-document navigations
198
+ // This is purely CDP-level — no script injection, safe from CloudFlare detection
199
+ try {
200
+ const cdp = await page.createCDPSession();
201
+ this.cdpSessions.set(sessionId, cdp);
69
202
 
70
- // Monitor for hash changes and other URL changes
71
- const checkUrlChange = () => {
72
- const currentUrl = window.location.href;
73
- if (currentUrl !== lastUrl) {
74
- lastUrl = currentUrl;
203
+ await cdp.send('Page.enable');
75
204
 
76
- // Store the new URL for the backend to detect
77
- (window as any).__urlChanged = {
78
- url: currentUrl,
79
- timestamp: Date.now()
80
- };
81
- }
82
- };
205
+ // Get main frame ID via CDP (reliable across Puppeteer versions)
206
+ const frameTree = await cdp.send('Page.getFrameTree');
207
+ const mainFrameId = frameTree.frameTree.frame.id;
83
208
 
84
- // Listen to various events that might change URL
85
- window.addEventListener('hashchange', checkUrlChange);
86
- window.addEventListener('popstate', checkUrlChange);
209
+ cdp.on('Page.navigatedWithinDocument', (params: { frameId: string; url: string }) => {
210
+ // Only track main frame SPA navigations (ignore iframe pushState)
211
+ if (params.frameId !== mainFrameId) return;
87
212
 
88
- // Periodically check for URL changes (for SPA navigation)
89
- setInterval(checkUrlChange, 500);
90
- });
91
- */
213
+ const newUrl = params.url;
214
+ if (newUrl === session.url) return;
215
+
216
+ debug.log('preview', `šŸ”„ SPA navigation detected: ${session.url} → ${newUrl}`);
217
+
218
+ // Update session URL
219
+ session.url = newUrl;
220
+
221
+ // Emit SPA navigation event — no loading state, no stream restart
222
+ this.emit('navigation-spa', {
223
+ sessionId,
224
+ type: 'navigation-spa',
225
+ url: newUrl,
226
+ timestamp: Date.now()
227
+ });
228
+ });
229
+
230
+ debug.log('preview', `āœ… CDP SPA navigation tracking setup for session: ${sessionId}`);
231
+ } catch (error) {
232
+ debug.warn('preview', `āš ļø Failed to setup CDP SPA tracking for ${sessionId}:`, error);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Cleanup CDP session for a tab
238
+ */
239
+ async cleanupSession(sessionId: string) {
240
+ const cdp = this.cdpSessions.get(sessionId);
241
+ if (cdp) {
242
+ try {
243
+ await cdp.detach();
244
+ } catch {
245
+ // Ignore detach errors
246
+ }
247
+ this.cdpSessions.delete(sessionId);
248
+ }
92
249
  }
93
250
 
94
251
  async navigateSession(sessionId: string, session: BrowserTab, url: string): Promise<string> {
@@ -51,7 +51,7 @@ const CHROMIUM_ARGS = [
51
51
  '--disable-blink-features=AutomationControlled',
52
52
  '--window-size=1366,768',
53
53
  '--autoplay-policy=no-user-gesture-required',
54
- '--disable-features=AudioServiceOutOfProcess'
54
+ '--disable-features=AudioServiceOutOfProcess,WebRtcHideLocalIpsWithMdns'
55
55
  ];
56
56
 
57
57
  class BrowserPool {
@@ -94,6 +94,7 @@ export class BrowserPreviewService extends EventEmitter {
94
94
  });
95
95
 
96
96
  // Forward navigation events and handle video streaming restart
97
+ // Only full navigations (framenavigated) need streaming restart
97
98
  this.navigationTracker.on('navigation', async (data) => {
98
99
  this.emit('preview:browser-navigation', data);
99
100
 
@@ -121,6 +122,12 @@ export class BrowserPreviewService extends EventEmitter {
121
122
  this.emit('preview:browser-navigation-loading', data);
122
123
  });
123
124
 
125
+ // Forward SPA navigation events (pushState/replaceState)
126
+ // No streaming restart needed — page context is unchanged
127
+ this.navigationTracker.on('navigation-spa', (data) => {
128
+ this.emit('preview:browser-navigation-spa', data);
129
+ });
130
+
124
131
  // Forward new window events
125
132
  this.tabManager.on('new-window', (data) => {
126
133
  this.emit('preview:browser-new-window', data);
@@ -140,6 +147,10 @@ export class BrowserPreviewService extends EventEmitter {
140
147
  this.emit('preview:browser-tab-navigated', data);
141
148
  });
142
149
 
150
+ this.tabManager.on('preview:browser-viewport-changed', (data) => {
151
+ this.emit('preview:browser-viewport-changed', data);
152
+ });
153
+
143
154
  // Forward video capture events
144
155
  this.videoCapture.on('ice-candidate', (data) => {
145
156
  this.emit('preview:browser-webcodecs-ice-candidate', data);
@@ -249,6 +260,9 @@ export class BrowserPreviewService extends EventEmitter {
249
260
  // Stop WebCodecs streaming first
250
261
  await this.stopWebCodecsStreaming(tabId);
251
262
 
263
+ // Cleanup navigation tracker CDP session
264
+ await this.navigationTracker.cleanupSession(tabId);
265
+
252
266
  // Clear cursor tracking for this tab
253
267
  this.interactionHandler.clearSessionCursor(tabId);
254
268
 
@@ -650,6 +664,11 @@ class BrowserPreviewServiceManager {
650
664
  ws.emit.project(projectId, 'preview:browser-navigation', data);
651
665
  });
652
666
 
667
+ // Forward SPA navigation events (pushState/replaceState — URL-only update)
668
+ service.on('preview:browser-navigation-spa', (data) => {
669
+ ws.emit.project(projectId, 'preview:browser-navigation-spa', data);
670
+ });
671
+
653
672
  // Forward tab events
654
673
  service.on('preview:browser-tab-opened', (data) => {
655
674
  debug.log('preview', `šŸš€ Forwarding preview:browser-tab-opened to project ${projectId}:`, data);
@@ -668,6 +687,10 @@ class BrowserPreviewServiceManager {
668
687
  ws.emit.project(projectId, 'preview:browser-tab-navigated', data);
669
688
  });
670
689
 
690
+ service.on('preview:browser-viewport-changed', (data) => {
691
+ ws.emit.project(projectId, 'preview:browser-viewport-changed', data);
692
+ });
693
+
671
694
  // Forward console events
672
695
  service.on('preview:browser-console-message', (data) => {
673
696
  ws.emit.project(projectId, 'preview:browser-console-message', data);
@@ -730,6 +730,21 @@ export class BrowserTabManager extends EventEmitter {
730
730
  // await page.evaluateOnNewDocument(cursorTrackingScript);
731
731
  }
732
732
 
733
+ /**
734
+ * Returns true for errors where retrying is pointless because the page/session is gone.
735
+ */
736
+ private isNonRetryableError(error: unknown): boolean {
737
+ if (error instanceof Error) {
738
+ const msg = error.message;
739
+ return (
740
+ msg.includes('Session closed') ||
741
+ msg.includes('detached Frame') ||
742
+ error.constructor.name === 'TargetCloseError'
743
+ );
744
+ }
745
+ return false;
746
+ }
747
+
733
748
  /**
734
749
  * Navigate with retry, including Cloudflare auto-pass detection and CAPTCHA popup dismissal.
735
750
  */
@@ -750,7 +765,7 @@ export class BrowserTabManager extends EventEmitter {
750
765
  } catch (error) {
751
766
  retries--;
752
767
  debug.warn('preview', `āš ļø Navigation failed, ${retries} retries left:`, error);
753
- if (retries === 0) throw error;
768
+ if (retries === 0 || this.isNonRetryableError(error)) throw error;
754
769
 
755
770
  // Wait before retry
756
771
  await new Promise(resolve => setTimeout(resolve, 2000));
@@ -383,8 +383,8 @@ export class BrowserVideoCapture extends EventEmitter {
383
383
  return null;
384
384
  }
385
385
 
386
- const maxRetries = 3;
387
- const retryDelay = 50;
386
+ const maxRetries = 6;
387
+ const retryDelay = 150;
388
388
 
389
389
  for (let attempt = 0; attempt < maxRetries; attempt++) {
390
390
  try {
@@ -38,6 +38,23 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
38
38
  // STUN servers are unnecessary for localhost and add 100-500ms ICE gathering latency
39
39
  const iceServers: { urls: string }[] = [];
40
40
 
41
+ // Create a loopback (127.0.0.1) copy of a host ICE candidate.
42
+ // Ensures WebRTC connects via loopback when VPN (e.g. Cloudflare WARP)
43
+ // interferes with host candidate connectivity between same-machine peers.
44
+ function createLoopbackCandidate(candidate: { candidate?: string; sdpMid?: string | null; sdpMLineIndex?: number | null }) {
45
+ if (!candidate.candidate) return null;
46
+ if (!candidate.candidate.includes('typ host')) return null;
47
+
48
+ const parts = candidate.candidate.split(' ');
49
+ if (parts.length < 8) return null;
50
+
51
+ const address = parts[4];
52
+ if (address === '127.0.0.1' || address === '::1') return null;
53
+
54
+ parts[4] = '127.0.0.1';
55
+ return { ...candidate, candidate: parts.join(' ') };
56
+ }
57
+
41
58
  // Check cursor style from page
42
59
  function checkCursor() {
43
60
  try {
@@ -103,11 +120,18 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
103
120
  // Handle ICE candidates
104
121
  peerConnection.onicecandidate = (event) => {
105
122
  if (event.candidate && (window as any).__sendIceCandidate) {
106
- (window as any).__sendIceCandidate({
123
+ const candidateInit = {
107
124
  candidate: event.candidate.candidate,
108
125
  sdpMid: event.candidate.sdpMid,
109
126
  sdpMLineIndex: event.candidate.sdpMLineIndex
110
- });
127
+ };
128
+ (window as any).__sendIceCandidate(candidateInit);
129
+
130
+ // Also send loopback version for VPN compatibility (same-machine peers)
131
+ const loopback = createLoopbackCandidate(candidateInit);
132
+ if (loopback) {
133
+ (window as any).__sendIceCandidate(loopback);
134
+ }
111
135
  }
112
136
  };
113
137
 
@@ -337,16 +361,27 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
337
361
  }
338
362
  }
339
363
 
340
- // Add ICE candidate
364
+ // Add ICE candidate (+ loopback variant for VPN compatibility)
341
365
  async function addIceCandidate(candidate: RTCIceCandidateInit) {
342
366
  if (!peerConnection) return false;
343
367
 
344
368
  try {
345
369
  await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
346
- return true;
347
370
  } catch (error) {
348
371
  return false;
349
372
  }
373
+
374
+ // Also try loopback version for VPN compatibility (same-machine peers)
375
+ const loopback = createLoopbackCandidate(candidate);
376
+ if (loopback) {
377
+ try {
378
+ await peerConnection.addIceCandidate(new RTCIceCandidate(loopback as RTCIceCandidateInit));
379
+ } catch {
380
+ // Expected to fail if loopback is not applicable
381
+ }
382
+ }
383
+
384
+ return true;
350
385
  }
351
386
 
352
387
  // Reconfigure video encoder with new dimensions (hot-swap)
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { IPty } from 'bun-pty';
7
- import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
7
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs';
8
8
  import { join } from 'path';
9
9
 
10
10
  interface TerminalStream {
@@ -129,36 +129,50 @@ class TerminalStreamManager {
129
129
  }
130
130
  }
131
131
 
132
+ /** Pending write flag to coalesce rapid writes */
133
+ private pendingWrites = new Set<string>();
134
+
132
135
  /**
133
- * Persist output to disk for cross-project persistence
136
+ * Persist output to disk for cross-project persistence (async, coalesced)
134
137
  */
135
138
  private persistOutputToDisk(stream: TerminalStream): void {
136
- try {
137
- const cacheFile = join(this.tempDir, `${stream.sessionId}.json`);
139
+ // Coalesce rapid writes - only schedule one write per session per microtask
140
+ if (this.pendingWrites.has(stream.sessionId)) return;
141
+ this.pendingWrites.add(stream.sessionId);
138
142
 
139
- // Only save new output (from outputStartIndex onwards)
140
- // This prevents duplicating old output that was already displayed
141
- const newOutput = stream.outputStartIndex !== undefined
142
- ? stream.output.slice(stream.outputStartIndex)
143
- : stream.output;
143
+ queueMicrotask(() => {
144
+ this.pendingWrites.delete(stream.sessionId);
144
145
 
145
- const cacheData = {
146
- streamId: stream.streamId,
147
- sessionId: stream.sessionId,
148
- command: stream.command,
149
- projectId: stream.projectId,
150
- projectPath: stream.projectPath,
151
- workingDirectory: stream.workingDirectory,
152
- startedAt: stream.startedAt,
153
- status: stream.status,
154
- output: newOutput, // Only save new output
155
- outputStartIndex: stream.outputStartIndex || 0,
156
- lastUpdated: new Date().toISOString()
157
- };
158
- writeFileSync(cacheFile, JSON.stringify(cacheData));
159
- } catch (error) {
160
- // Silently handle write errors
161
- }
146
+ try {
147
+ const cacheFile = join(this.tempDir, `${stream.sessionId}.json`);
148
+
149
+ // Only save new output (from outputStartIndex onwards)
150
+ const newOutput = stream.outputStartIndex !== undefined
151
+ ? stream.output.slice(stream.outputStartIndex)
152
+ : stream.output;
153
+
154
+ const cacheData = {
155
+ streamId: stream.streamId,
156
+ sessionId: stream.sessionId,
157
+ command: stream.command,
158
+ projectId: stream.projectId,
159
+ projectPath: stream.projectPath,
160
+ workingDirectory: stream.workingDirectory,
161
+ startedAt: stream.startedAt,
162
+ status: stream.status,
163
+ output: newOutput,
164
+ outputStartIndex: stream.outputStartIndex || 0,
165
+ lastUpdated: new Date().toISOString()
166
+ };
167
+
168
+ // Use Bun.write for non-blocking async disk write
169
+ Bun.write(cacheFile, JSON.stringify(cacheData)).catch(() => {
170
+ // Silently handle write errors
171
+ });
172
+ } catch {
173
+ // Silently handle errors
174
+ }
175
+ });
162
176
  }
163
177
 
164
178
  /**
@@ -252,6 +252,17 @@ export const streamPreviewHandler = createRouter()
252
252
  url: t.String(),
253
253
  timestamp: t.Number()
254
254
  })
255
+ )
256
+
257
+ // Server → Client: SPA navigation (pushState/replaceState — URL-only update, no page reload)
258
+ .emit(
259
+ 'preview:browser-navigation-spa',
260
+ t.Object({
261
+ sessionId: t.String(),
262
+ type: t.String(),
263
+ url: t.String(),
264
+ timestamp: t.Number()
265
+ })
255
266
  );
256
267
 
257
268
  // Setup event forwarding from preview service to WebSocket
@@ -143,4 +143,12 @@ export const previewRouter = createRouter()
143
143
  sessionId: t.String(),
144
144
  timestamp: t.Number(),
145
145
  source: t.Literal('mcp')
146
+ }))
147
+ .emit('preview:browser-viewport-changed', t.Object({
148
+ tabId: t.String(),
149
+ deviceSize: t.String(),
150
+ rotation: t.String(),
151
+ width: t.Number(),
152
+ height: t.Number(),
153
+ timestamp: t.Number()
146
154
  }));
@@ -10,10 +10,13 @@
10
10
  import { t } from 'elysia';
11
11
  import { join } from 'node:path';
12
12
  import { readFileSync } from 'node:fs';
13
+ import fs from 'node:fs/promises';
13
14
  import { createRouter } from '$shared/utils/ws-server';
14
15
  import { initializeDatabase, getDatabase } from '../../database';
15
16
  import { debug } from '$shared/utils/logger';
16
17
  import { ws } from '$backend/utils/ws';
18
+ import { getClopenDir } from '$backend/utils/index';
19
+ import { resetEnvironment } from '$backend/engine/adapters/claude/environment';
17
20
 
18
21
  /** In-memory flag: set after successful update, cleared on server restart */
19
22
  let pendingUpdate: { fromVersion: string; toVersion: string } | null = null;
@@ -163,6 +166,26 @@ export const operationsHandler = createRouter()
163
166
 
164
167
  debug.log('server', 'Database cleared successfully');
165
168
 
169
+ // Delete snapshots directory
170
+ const clopenDir = getClopenDir();
171
+ const snapshotsDir = join(clopenDir, 'snapshots');
172
+ try {
173
+ await fs.rm(snapshotsDir, { recursive: true, force: true });
174
+ debug.log('server', 'Snapshots directory cleared');
175
+ } catch (err) {
176
+ debug.warn('server', 'Failed to clear snapshots directory:', err);
177
+ }
178
+
179
+ // Delete Claude config directory and reset environment state
180
+ const claudeDir = join(clopenDir, 'claude');
181
+ try {
182
+ await fs.rm(claudeDir, { recursive: true, force: true });
183
+ resetEnvironment();
184
+ debug.log('server', 'Claude config directory cleared');
185
+ } catch (err) {
186
+ debug.warn('server', 'Failed to clear Claude config directory:', err);
187
+ }
188
+
166
189
  return {
167
190
  cleared: true,
168
191
  tablesCount: tables.length
@@ -416,12 +416,12 @@
416
416
  ondrop={fileHandling.handleDrop}
417
417
  >
418
418
  <div class="flex-1">
419
- <!-- Engine/Model Picker -->
420
- <EngineModelPicker />
421
-
422
419
  <!-- Edit Mode Indicator -->
423
420
  <EditModeIndicator onCancel={handleCancelEdit} />
424
421
 
422
+ <!-- Engine/Model Picker -->
423
+ <EngineModelPicker />
424
+
425
425
  <div class="flex items-end">
426
426
  <textarea
427
427
  bind:this={textareaElement}
@@ -50,7 +50,7 @@
50
50
  {#if badges.length > 0}
51
51
  <div class="flex gap-2 mt-3">
52
52
  {#each badges as badge}
53
- <div class="text-xs px-2 py-1 rounded {badge.color}">
53
+ <div class="text-3xs px-2 py-0.5 rounded {badge.color}">
54
54
  {badge.text}
55
55
  </div>
56
56
  {/each}