@myrialabs/clopen 0.2.7 → 0.2.9

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 (44) hide show
  1. package/backend/chat/stream-manager.ts +23 -12
  2. package/backend/mcp/project-context.ts +20 -0
  3. package/backend/mcp/servers/browser-automation/actions.ts +0 -2
  4. package/backend/mcp/servers/browser-automation/browser.ts +80 -143
  5. package/backend/mcp/servers/browser-automation/inspection.ts +5 -11
  6. package/backend/preview/browser/browser-mcp-control.ts +174 -195
  7. package/backend/preview/browser/browser-preview-service.ts +3 -3
  8. package/backend/preview/browser/browser-video-capture.ts +12 -14
  9. package/backend/preview/browser/scripts/video-stream.ts +14 -14
  10. package/backend/preview/browser/types.ts +7 -7
  11. package/backend/preview/index.ts +1 -1
  12. package/backend/terminal/stream-manager.ts +40 -26
  13. package/backend/ws/preview/index.ts +3 -3
  14. package/backend/ws/system/operations.ts +23 -0
  15. package/frontend/components/chat/message/MessageBubble.svelte +2 -2
  16. package/frontend/components/chat/tools/components/FileHeader.svelte +1 -1
  17. package/frontend/components/chat/tools/components/TerminalCommand.svelte +8 -1
  18. package/frontend/components/common/overlay/Dialog.svelte +1 -1
  19. package/frontend/components/common/overlay/Lightbox.svelte +2 -2
  20. package/frontend/components/common/overlay/Modal.svelte +2 -2
  21. package/frontend/components/common/xterm/XTerm.svelte +6 -1
  22. package/frontend/components/git/ConflictResolver.svelte +1 -1
  23. package/frontend/components/git/GitModal.svelte +2 -2
  24. package/frontend/components/preview/browser/BrowserPreview.svelte +1 -1
  25. package/frontend/components/preview/browser/components/Canvas.svelte +1 -1
  26. package/frontend/components/preview/browser/components/Toolbar.svelte +4 -4
  27. package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +58 -64
  28. package/frontend/components/settings/SettingsModal.svelte +1 -1
  29. package/frontend/components/settings/general/DataManagementSettings.svelte +5 -66
  30. package/frontend/components/terminal/Terminal.svelte +1 -29
  31. package/frontend/components/tunnel/TunnelInactive.svelte +7 -5
  32. package/frontend/components/workspace/DesktopNavigator.svelte +1 -1
  33. package/frontend/components/workspace/PanelHeader.svelte +22 -16
  34. package/frontend/components/workspace/panels/GitPanel.svelte +1 -6
  35. package/frontend/services/preview/browser/browser-webcodecs.service.ts +2 -2
  36. package/frontend/services/project/status.service.ts +11 -1
  37. package/frontend/stores/core/sessions.svelte.ts +11 -1
  38. package/frontend/stores/features/terminal.svelte.ts +56 -26
  39. package/frontend/stores/ui/theme.svelte.ts +1 -1
  40. package/frontend/utils/ws.ts +42 -0
  41. package/index.html +2 -2
  42. package/package.json +1 -1
  43. package/shared/utils/ws-client.ts +21 -4
  44. package/static/manifest.json +2 -2
@@ -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
  >
@@ -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,7 +387,7 @@
383
387
  {/if} -->
384
388
 
385
389
  <!-- Device size dropdown -->
386
- <div class="relative {isMobile ? '' : 'mr-1.5'}">
390
+ <div class="relative">
387
391
  <button
388
392
  type="button"
389
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 text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
@@ -481,20 +485,22 @@
481
485
  {/if}
482
486
  </div>
483
487
 
484
- <!-- Touch mode toggle (scroll ↔ trackpad cursor) -->
485
- <button
486
- type="button"
487
- class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md cursor-pointer transition-all duration-150 hover:bg-violet-500/10
488
- {previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 hover:text-slate-900 dark:hover:text-slate-100'}"
489
- onclick={() => {
490
- const current = previewPanelRef?.panelActions?.getTouchMode() || 'scroll';
491
- previewPanelRef?.panelActions?.setTouchMode(current === 'scroll' ? 'cursor' : 'scroll');
492
- }}
493
- title={previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'Trackpad mode: 1-finger moves cursor, tap=click, 2-finger scroll/right-click' : 'Scroll mode: touch scrolls the page (tap to click)'}
494
- >
495
- <Icon name={previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'lucide:mouse-pointer-2' : 'lucide:pointer'} class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
496
- <span class="text-xs font-medium">{previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'Cursor' : 'Touch'}</span>
497
- </button>
488
+ <!-- Touch mode toggle (scroll ↔ trackpad cursor) — only shown on touchscreen devices -->
489
+ {#if isTouchDevice}
490
+ <button
491
+ type="button"
492
+ 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
493
+ {previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 hover:text-slate-900 dark:hover:text-slate-100'}"
494
+ onclick={() => {
495
+ const current = previewPanelRef?.panelActions?.getTouchMode() || 'scroll';
496
+ previewPanelRef?.panelActions?.setTouchMode(current === 'scroll' ? 'cursor' : 'scroll');
497
+ }}
498
+ 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)'}
499
+ >
500
+ <Icon name={previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'lucide:mouse-pointer-2' : 'lucide:pointer'} class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
501
+ <span class="text-xs font-medium">{previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'Cursor' : 'Touch'}</span>
502
+ </button>
503
+ {/if}
498
504
 
499
505
  <!-- Rotation toggle -->
500
506
  <button
@@ -510,7 +516,7 @@
510
516
  </button>
511
517
 
512
518
  <!-- Scale info badge -->
513
- <div class="flex items-center gap-1.5 {isMobile ? 'px-2.5 h-9 bg-transparent' : 'px-2 h-6 bg-slate-100/60 dark:bg-slate-800/40'} rounded-md text-xs font-medium text-slate-500">
519
+ <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
520
  <Icon name="lucide:move-diagonal" class={isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} />
515
521
  <span>{Math.round((previewPanelRef?.panelActions?.getScale() || 1) * 100)}%</span>
516
522
  </div>
@@ -1426,12 +1426,7 @@
1426
1426
  <div class="space-y-1 px-1">
1427
1427
  {#each tags as tag (tag.name)}
1428
1428
  <div class="group flex items-center gap-2 px-2.5 py-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800/60 transition-colors">
1429
- <span title={tag.isAnnotated ? 'Annotated tag' : 'Lightweight tag'} class="shrink-0">
1430
- <Icon
1431
- name={tag.isAnnotated ? 'lucide:bookmark' : 'lucide:tag'}
1432
- class="w-4 h-4 {tag.isAnnotated ? 'text-amber-500' : 'text-slate-400'}"
1433
- />
1434
- </span>
1429
+ <Icon name="lucide:tag" class="w-4 h-4 text-slate-400 shrink-0" />
1435
1430
  <div class="flex-1 min-w-0">
1436
1431
  <p class="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{tag.name}</p>
1437
1432
  <div class="flex items-center gap-1.5">
@@ -208,7 +208,7 @@ export class BrowserWebCodecsService {
208
208
 
209
209
  if (this.ctx) {
210
210
  this.ctx.imageSmoothingEnabled = true;
211
- this.ctx.imageSmoothingQuality = 'low';
211
+ this.ctx.imageSmoothingQuality = 'medium';
212
212
  }
213
213
 
214
214
  this.clearCanvas();
@@ -1003,7 +1003,7 @@ export class BrowserWebCodecsService {
1003
1003
 
1004
1004
  if (this.ctx) {
1005
1005
  this.ctx.imageSmoothingEnabled = true;
1006
- this.ctx.imageSmoothingQuality = 'low';
1006
+ this.ctx.imageSmoothingQuality = 'medium';
1007
1007
  }
1008
1008
 
1009
1009
  try {
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { getOrCreateAnonymousUser, type AnonymousUser } from '$shared/utils/anonymous-user';
10
- import ws from '$frontend/utils/ws';
10
+ import ws, { onWsReconnect } from '$frontend/utils/ws';
11
11
  import { debug } from '$shared/utils/logger';
12
12
 
13
13
  export interface ProjectStatus {
@@ -44,6 +44,16 @@ class ProjectStatusService {
44
44
  this.currentUser = await getOrCreateAnonymousUser();
45
45
  debug.log('project', 'Initialized with user:', this.currentUser?.name);
46
46
 
47
+ // Re-join project presence after WebSocket reconnection.
48
+ // Without this, the new connection loses presence tracking and
49
+ // panels (Git, Terminal, Preview, etc.) miss status updates.
50
+ onWsReconnect(() => {
51
+ if (this.currentProjectId && this.currentUser) {
52
+ ws.emit('projects:join', { userName: this.currentUser.name });
53
+ debug.log('project', 'Re-joined project presence after reconnection');
54
+ }
55
+ });
56
+
47
57
  this.unsubscribe = ws.on('projects:presence-updated', (data) => {
48
58
  try {
49
59
  if (data.type === 'presence-updated' && data.data) {
@@ -10,7 +10,7 @@
10
10
  import type { ChatSession, SDKMessageFormatter } from '$shared/types/database/schema';
11
11
  import type { SDKMessage } from '$shared/types/messaging';
12
12
  import { buildMetadataFromTransport } from '$shared/utils/message-formatter';
13
- import ws from '$frontend/utils/ws';
13
+ import ws, { onWsReconnect } from '$frontend/utils/ws';
14
14
  import { projectState } from './projects.svelte';
15
15
  import { setupEditModeListener, restoreEditMode } from '$frontend/stores/ui/edit-mode.svelte';
16
16
  import { markSessionUnread, markSessionRead, appState } from '$frontend/stores/core/app.svelte';
@@ -376,6 +376,16 @@ export async function reloadSessionsForProject(): Promise<string | null> {
376
376
  * automatically switch to the new shared session.
377
377
  */
378
378
  function setupCollaborativeListeners() {
379
+ // Re-join chat session room after WebSocket reconnection.
380
+ // Without this, the new connection is not in the session room and
381
+ // misses all chat events (stream, partial, complete, input sync, etc.).
382
+ onWsReconnect(() => {
383
+ if (sessionState.currentSession?.id) {
384
+ ws.emit('chat:join-session', { chatSessionId: sessionState.currentSession.id });
385
+ debug.log('session', 'Re-joined chat session room after reconnection:', sessionState.currentSession.id);
386
+ }
387
+ });
388
+
379
389
  // Listen for new session available notifications from other users.
380
390
  // Does NOT auto-switch — adds session to list and shows notification.
381
391
  ws.on('sessions:session-available', async (data: { session: ChatSession }) => {
@@ -17,6 +17,7 @@ interface TerminalState {
17
17
  executingSessionIds: Set<string>; // Track multiple executing sessions
18
18
  sessionExecutionStates: Map<string, boolean>; // Track execution state per session
19
19
  lineBuffers: Map<string, string>; // Line buffering for chunked PTY output
20
+ flushTimers: Map<string, ReturnType<typeof setTimeout>>; // Auto-flush timers for buffered output
20
21
  }
21
22
 
22
23
  // Terminal store state
@@ -28,7 +29,8 @@ const terminalState = $state<TerminalState>({
28
29
  lastCommandWasCancelled: false,
29
30
  executingSessionIds: new Set(),
30
31
  sessionExecutionStates: new Map(),
31
- lineBuffers: new Map()
32
+ lineBuffers: new Map(),
33
+ flushTimers: new Map()
32
34
  });
33
35
 
34
36
  // Computed properties
@@ -214,8 +216,13 @@ export const terminalStore = {
214
216
  debug.error('terminal', `🔴 [closeSession] Failed to remove from project context:`, error);
215
217
  }
216
218
 
217
- // Clear any buffered content for this session
219
+ // Clear any buffered content and flush timers for this session
218
220
  terminalState.lineBuffers.delete(sessionId);
221
+ const closeFlushTimer = terminalState.flushTimers.get(sessionId);
222
+ if (closeFlushTimer) {
223
+ clearTimeout(closeFlushTimer);
224
+ terminalState.flushTimers.delete(sessionId);
225
+ }
219
226
 
220
227
  // Remove execution states for this session
221
228
  terminalState.sessionExecutionStates.delete(sessionId);
@@ -285,6 +292,13 @@ export const terminalStore = {
285
292
 
286
293
  // Flush any buffered content before cancel (if was executing)
287
294
  if (wasExecuting) {
295
+ // Clear flush timer first
296
+ const cancelFlushTimer = terminalState.flushTimers.get(activeSession.id);
297
+ if (cancelFlushTimer) {
298
+ clearTimeout(cancelFlushTimer);
299
+ terminalState.flushTimers.delete(activeSession.id);
300
+ }
301
+
288
302
  const remainingBuffer = terminalState.lineBuffers.get(activeSession.id);
289
303
  if (remainingBuffer && remainingBuffer.length > 0) {
290
304
  this.addLineToSession(activeSession.id, {
@@ -353,31 +367,41 @@ export const terminalStore = {
353
367
 
354
368
  // Process buffered output to handle chunked PTY data properly
355
369
  processBufferedOutput(sessionId: string, content: string, type: 'output' | 'error'): void {
356
- // Buffer incomplete lines to avoid splitting words like "Reply" into "R" and "eply"
370
+ // Clear any pending flush timer for this session
371
+ const existingTimer = terminalState.flushTimers.get(sessionId);
372
+ if (existingTimer) {
373
+ clearTimeout(existingTimer);
374
+ terminalState.flushTimers.delete(sessionId);
375
+ }
376
+
357
377
  let buffer = terminalState.lineBuffers.get(sessionId) || '';
358
378
  buffer += content;
359
-
360
- // Only send complete chunks to avoid word splitting
361
- // If buffer ends with a partial ANSI sequence or in middle of a word, wait for more
362
- if (buffer.length < 2) {
363
- // Very short buffer, likely incomplete - wait for more
364
- terminalState.lineBuffers.set(sessionId, buffer);
365
- return;
366
- }
367
-
379
+
368
380
  // Check if we're in the middle of an ANSI escape sequence
369
381
  const lastEscIndex = buffer.lastIndexOf('\x1b');
370
382
  if (lastEscIndex >= 0 && lastEscIndex > buffer.length - 10) {
371
- // Might be in middle of escape sequence, check if it's complete
372
383
  const remaining = buffer.substring(lastEscIndex);
373
384
  if (!/^(\x1b\[[0-9;]*[a-zA-Z]|\x1b\[\?[0-9]+[lh])/.test(remaining)) {
374
- // Incomplete escape sequence, wait for more
385
+ // Incomplete escape sequence - hold briefly, auto-flush after 8ms
375
386
  terminalState.lineBuffers.set(sessionId, buffer);
387
+ const flushTimer = setTimeout(() => {
388
+ terminalState.flushTimers.delete(sessionId);
389
+ const pending = terminalState.lineBuffers.get(sessionId);
390
+ if (pending && pending.length > 0) {
391
+ this.addLineToSession(sessionId, {
392
+ content: pending,
393
+ type: type,
394
+ timestamp: new Date()
395
+ });
396
+ terminalState.lineBuffers.set(sessionId, '');
397
+ }
398
+ }, 8);
399
+ terminalState.flushTimers.set(sessionId, flushTimer);
376
400
  return;
377
401
  }
378
402
  }
379
-
380
- // Send the buffer and clear it
403
+
404
+ // Flush immediately - no artificial delay for complete data
381
405
  if (buffer.length > 0) {
382
406
  this.addLineToSession(sessionId, {
383
407
  content: buffer,
@@ -390,15 +414,11 @@ export const terminalStore = {
390
414
 
391
415
  // Session Content Management
392
416
  addLineToSession(sessionId: string, line: TerminalLine): void {
393
- terminalState.sessions = terminalState.sessions.map(session =>
394
- session.id === sessionId
395
- ? {
396
- ...session,
397
- lines: [...session.lines, line],
398
- lastUsedAt: new Date()
399
- }
400
- : session
401
- );
417
+ const session = terminalState.sessions.find(s => s.id === sessionId);
418
+ if (session) {
419
+ session.lines.push(line);
420
+ session.lastUsedAt = new Date();
421
+ }
402
422
  },
403
423
 
404
424
  updateSessionHistory(sessionId: string, history: string[]): void {
@@ -411,8 +431,13 @@ export const terminalStore = {
411
431
 
412
432
 
413
433
  clearSession(sessionId: string): void {
414
- // Clear any buffered content for this session
434
+ // Clear any buffered content and flush timers for this session
415
435
  terminalState.lineBuffers.delete(sessionId);
436
+ const clearFlushTimer = terminalState.flushTimers.get(sessionId);
437
+ if (clearFlushTimer) {
438
+ clearTimeout(clearFlushTimer);
439
+ terminalState.flushTimers.delete(sessionId);
440
+ }
416
441
 
417
442
  // CRITICAL FIX: Actually clear the session lines history
418
443
  // This ensures when switching tabs, the cleared terminal stays clear
@@ -600,6 +625,11 @@ export const terminalStore = {
600
625
  */
601
626
  removeSessionFromStore(sessionId: string): void {
602
627
  terminalState.lineBuffers.delete(sessionId);
628
+ const removeFlushTimer = terminalState.flushTimers.get(sessionId);
629
+ if (removeFlushTimer) {
630
+ clearTimeout(removeFlushTimer);
631
+ terminalState.flushTimers.delete(sessionId);
632
+ }
603
633
  terminalState.sessionExecutionStates.delete(sessionId);
604
634
  terminalState.executingSessionIds.delete(sessionId);
605
635
  terminalState.sessions = terminalState.sessions.filter(s => s.id !== sessionId);
@@ -96,7 +96,7 @@ function updateThemeColor(mode: 'light' | 'dark') {
96
96
  }
97
97
 
98
98
  // Set appropriate theme color
99
- const themeColor = mode === 'dark' ? '#0a0a0a' : '#ffffff';
99
+ const themeColor = mode === 'dark' ? '#0e172b' : '#ffffff';
100
100
  metaThemeColor.setAttribute('content', themeColor);
101
101
  }
102
102
 
@@ -7,6 +7,7 @@
7
7
  import { WSClient } from '$shared/utils/ws-client';
8
8
  import type { WSAPI } from '$backend/ws';
9
9
  import { setConnectionStatus } from '$frontend/stores/ui/connection.svelte';
10
+ import { debug } from '$shared/utils/logger';
10
11
 
11
12
  /**
12
13
  * Get WebSocket URL based on environment
@@ -18,6 +19,28 @@ function getWebSocketUrl(): string {
18
19
  return `${protocol}//${host}/ws`;
19
20
  }
20
21
 
22
+ // ============================================================================
23
+ // Reconnect Handler Registry
24
+ // ============================================================================
25
+
26
+ /** Handlers to run after WebSocket reconnection (re-join rooms, restore subscriptions) */
27
+ const reconnectHandlers = new Set<() => void>();
28
+
29
+ /**
30
+ * Register a handler to run after WebSocket reconnection.
31
+ * Use this to re-join rooms (chat:join-session, projects:join) and
32
+ * restore subscriptions that are lost when the connection drops.
33
+ * Returns an unsubscribe function.
34
+ */
35
+ export function onWsReconnect(handler: () => void): () => void {
36
+ reconnectHandlers.add(handler);
37
+ return () => { reconnectHandlers.delete(handler); };
38
+ }
39
+
40
+ // ============================================================================
41
+ // WebSocket Client
42
+ // ============================================================================
43
+
21
44
  const ws = new WSClient<WSAPI>(getWebSocketUrl(), {
22
45
  autoReconnect: true,
23
46
  maxReconnectAttempts: 0, // Infinite reconnect
@@ -25,6 +48,16 @@ const ws = new WSClient<WSAPI>(getWebSocketUrl(), {
25
48
  maxReconnectDelay: 30000,
26
49
  onStatusChange: (status, reconnectAttempts) => {
27
50
  setConnectionStatus(status, reconnectAttempts);
51
+ },
52
+ onReconnect: () => {
53
+ debug.log('websocket', `Running ${reconnectHandlers.size} reconnect handler(s)`);
54
+ for (const handler of reconnectHandlers) {
55
+ try {
56
+ handler();
57
+ } catch (err) {
58
+ debug.error('websocket', 'Reconnect handler error:', err);
59
+ }
60
+ }
28
61
  }
29
62
  });
30
63
 
@@ -45,4 +78,13 @@ window.addEventListener('beforeunload', () => {
45
78
  ws.disconnect();
46
79
  });
47
80
 
81
+ // Force reload when page is restored from bfcache (back-forward cache).
82
+ // After beforeunload, all WS listeners are cleared and the connection is dead.
83
+ // A full reload ensures all state (handlers, room subscriptions) is re-initialized.
84
+ window.addEventListener('pageshow', (event) => {
85
+ if (event.persisted) {
86
+ window.location.reload();
87
+ }
88
+ });
89
+
48
90
  export default ws;
package/index.html CHANGED
@@ -9,7 +9,7 @@
9
9
  name="description"
10
10
  content="Clopen - Modern web UI for Claude Code & OpenCode with real browser preview, git management, multi-account support, file management, checkpoints, collaboration, and integrated terminal. Built with Bun and Svelte 5."
11
11
  />
12
- <meta name="theme-color" content="#0a0a0a" />
12
+ <meta name="theme-color" content="#0e172b" />
13
13
  <title>Clopen</title>
14
14
 
15
15
  <!-- DM Sans - Local self-hosted font -->
@@ -47,7 +47,7 @@
47
47
  // Update meta theme-color for mobile browsers
48
48
  const metaThemeColor = document.querySelector('meta[name="theme-color"]');
49
49
  if (metaThemeColor) {
50
- metaThemeColor.setAttribute('content', isDark ? '#0a0a0a' : '#ffffff');
50
+ metaThemeColor.setAttribute('content', isDark ? '#0e172b' : '#ffffff');
51
51
  }
52
52
  } catch (e) {
53
53
  // Fallback to system preference if anything fails
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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",