@myrialabs/clopen 0.2.8 → 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 (26) hide show
  1. package/backend/terminal/stream-manager.ts +40 -26
  2. package/backend/ws/system/operations.ts +23 -0
  3. package/frontend/components/chat/tools/components/FileHeader.svelte +1 -1
  4. package/frontend/components/chat/tools/components/TerminalCommand.svelte +8 -1
  5. package/frontend/components/common/overlay/Dialog.svelte +1 -1
  6. package/frontend/components/common/overlay/Lightbox.svelte +2 -2
  7. package/frontend/components/common/overlay/Modal.svelte +2 -2
  8. package/frontend/components/common/xterm/XTerm.svelte +6 -1
  9. package/frontend/components/git/ConflictResolver.svelte +1 -1
  10. package/frontend/components/git/GitModal.svelte +2 -2
  11. package/frontend/components/preview/browser/components/Toolbar.svelte +1 -1
  12. package/frontend/components/settings/SettingsModal.svelte +1 -1
  13. package/frontend/components/settings/general/DataManagementSettings.svelte +5 -66
  14. package/frontend/components/terminal/Terminal.svelte +1 -29
  15. package/frontend/components/tunnel/TunnelInactive.svelte +7 -5
  16. package/frontend/components/workspace/DesktopNavigator.svelte +1 -1
  17. package/frontend/components/workspace/PanelHeader.svelte +22 -16
  18. package/frontend/services/project/status.service.ts +11 -1
  19. package/frontend/stores/core/sessions.svelte.ts +11 -1
  20. package/frontend/stores/features/terminal.svelte.ts +56 -26
  21. package/frontend/stores/ui/theme.svelte.ts +1 -1
  22. package/frontend/utils/ws.ts +42 -0
  23. package/index.html +2 -2
  24. package/package.json +1 -1
  25. package/shared/utils/ws-client.ts +21 -4
  26. package/static/manifest.json +2 -2
@@ -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
  /**
@@ -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
@@ -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}
@@ -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}
@@ -214,7 +214,7 @@
214
214
  </script>
215
215
 
216
216
  <!-- Preview Toolbar -->
217
- <div class="relative bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
217
+ <div class="relative bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
218
218
  <!-- Tabs bar (Git-style underline tabs) — separated with its own border-bottom -->
219
219
  {#if tabs.length > 0}
220
220
  <div class="relative flex items-center overflow-x-auto border-b border-slate-200 dark:border-slate-700">
@@ -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
  >
@@ -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>
@@ -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.8",
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",
@@ -56,6 +56,8 @@ export interface WSClientOptions {
56
56
  maxReconnectDelay?: number;
57
57
  /** Callback when connection status changes */
58
58
  onStatusChange?: (status: WSConnectionStatus, reconnectAttempts: number) => void;
59
+ /** Callback when WebSocket reconnects (not on initial connection) */
60
+ onReconnect?: () => void;
59
61
  }
60
62
 
61
63
  // ============================================================================
@@ -204,13 +206,14 @@ function decodeBinaryMessage(buffer: ArrayBuffer): { action: string; payload: an
204
206
  export class WSClient<TAPI extends { client: any; server: any }> {
205
207
  private ws: WebSocket | null = null;
206
208
  private url: string;
207
- private options: Required<Omit<WSClientOptions, 'onStatusChange'>> & Pick<WSClientOptions, 'onStatusChange'>;
209
+ private options: Required<Omit<WSClientOptions, 'onStatusChange' | 'onReconnect'>> & Pick<WSClientOptions, 'onStatusChange' | 'onReconnect'>;
208
210
  private reconnectAttempts = 0;
209
211
  private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
210
212
  private listeners = new Map<string, Set<(payload: any) => void>>();
211
213
  private messageQueue: Array<{ action: string; payload: any }> = [];
212
214
  private isConnected = false;
213
215
  private shouldReconnect = true;
216
+ private hasConnectedBefore = false;
214
217
 
215
218
  /** Current context (synced with server) */
216
219
  private context: {
@@ -237,7 +240,8 @@ export class WSClient<TAPI extends { client: any; server: any }> {
237
240
  maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
238
241
  reconnectDelay: options.reconnectDelay ?? 1000,
239
242
  maxReconnectDelay: options.maxReconnectDelay ?? 30000,
240
- onStatusChange: options.onStatusChange ?? undefined
243
+ onStatusChange: options.onStatusChange ?? undefined,
244
+ onReconnect: options.onReconnect ?? undefined
241
245
  };
242
246
 
243
247
  this.connect();
@@ -295,7 +299,20 @@ export class WSClient<TAPI extends { client: any; server: any }> {
295
299
  }
296
300
  }
297
301
 
298
- // Flush queued messages AFTER context is synced
302
+ // Fire reconnect handlers BEFORE queue flush so room
303
+ // subscriptions (chat:join-session, projects:join) are
304
+ // restored before any queued messages are sent.
305
+ const isReconnect = this.hasConnectedBefore;
306
+ this.hasConnectedBefore = true;
307
+ if (isReconnect) {
308
+ try {
309
+ this.options.onReconnect?.();
310
+ } catch (err) {
311
+ debug.error('websocket', 'onReconnect callback error:', err);
312
+ }
313
+ }
314
+
315
+ // Flush queued messages AFTER context + room re-joins are synced
299
316
  while (this.messageQueue.length > 0) {
300
317
  const msg = this.messageQueue.shift();
301
318
  if (msg) {
@@ -303,7 +320,7 @@ export class WSClient<TAPI extends { client: any; server: any }> {
303
320
  }
304
321
  }
305
322
 
306
- // Resolve waitUntilConnected() callers AFTER context sync + queue flush
323
+ // Resolve waitUntilConnected() callers AFTER everything is ready
307
324
  for (const resolve of this.connectResolvers) {
308
325
  resolve();
309
326
  }
@@ -4,8 +4,8 @@
4
4
  "description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
5
5
  "start_url": "/",
6
6
  "display": "standalone",
7
- "background_color": "#0f172a",
8
- "theme_color": "#0f172a",
7
+ "background_color": "#0e172b",
8
+ "theme_color": "#0e172b",
9
9
  "icons": [
10
10
  {
11
11
  "src": "/favicon.svg",