@myrialabs/clopen 0.1.5 → 0.1.7

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 (28) hide show
  1. package/README.md +7 -1
  2. package/backend/ws/system/operations.ts +95 -0
  3. package/bin/clopen.ts +89 -0
  4. package/bun.lock +5 -203
  5. package/frontend/App.svelte +24 -7
  6. package/frontend/lib/components/chat/ChatInterface.svelte +2 -2
  7. package/frontend/lib/components/checkpoint/TimelineModal.svelte +1 -1
  8. package/frontend/lib/components/common/ConnectionBanner.svelte +55 -0
  9. package/frontend/lib/components/common/UpdateBanner.svelte +88 -0
  10. package/frontend/lib/components/files/FileTree.svelte +34 -23
  11. package/frontend/lib/components/history/HistoryModal.svelte +5 -0
  12. package/frontend/lib/components/settings/SettingsModal.svelte +2 -2
  13. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  14. package/frontend/lib/components/settings/general/UpdateSettings.svelte +123 -0
  15. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +1 -1
  16. package/frontend/lib/stores/core/app.svelte.ts +47 -0
  17. package/frontend/lib/stores/core/presence.svelte.ts +30 -13
  18. package/frontend/lib/stores/core/projects.svelte.ts +10 -2
  19. package/frontend/lib/stores/core/sessions.svelte.ts +15 -2
  20. package/frontend/lib/stores/features/settings.svelte.ts +2 -1
  21. package/frontend/lib/stores/ui/connection.svelte.ts +40 -0
  22. package/frontend/lib/stores/ui/update.svelte.ts +124 -0
  23. package/frontend/lib/utils/ws.ts +5 -1
  24. package/index.html +1 -1
  25. package/package.json +2 -10
  26. package/shared/types/stores/settings.ts +2 -0
  27. package/shared/utils/ws-client.ts +16 -2
  28. package/vite.config.ts +1 -0
@@ -13,6 +13,7 @@ import { buildMetadataFromTransport } from '$shared/utils/message-formatter';
13
13
  import ws from '$frontend/lib/utils/ws';
14
14
  import { projectState } from './projects.svelte';
15
15
  import { setupEditModeListener, restoreEditMode } from '$frontend/lib/stores/ui/edit-mode.svelte';
16
+ import { markSessionUnread, markSessionRead } from '$frontend/lib/stores/core/app.svelte';
16
17
  import { debug } from '$shared/utils/logger';
17
18
 
18
19
  interface SessionState {
@@ -56,6 +57,11 @@ export async function setCurrentSession(session: ChatSession | null, skipLoadMes
56
57
  const previousSessionId = sessionState.currentSession?.id;
57
58
  sessionState.currentSession = session;
58
59
 
60
+ // Clear unread status when viewing a session
61
+ if (session) {
62
+ markSessionRead(session.id);
63
+ }
64
+
59
65
  // Leave previous chat session room
60
66
  if (previousSessionId && previousSessionId !== session?.id) {
61
67
  ws.emit('chat:leave-session', { chatSessionId: previousSessionId });
@@ -300,11 +306,11 @@ export function getRecentSessions(limit: number = 10): ChatSession[] {
300
306
  * Reload sessions for the current project from the server.
301
307
  * Called when the user switches projects so session list stays in sync.
302
308
  */
303
- export async function reloadSessionsForProject() {
309
+ export async function reloadSessionsForProject(): Promise<string | null> {
304
310
  try {
305
311
  const response = await ws.http('sessions:list');
306
312
  if (response) {
307
- const { sessions } = response;
313
+ const { sessions, currentSessionId } = response;
308
314
  // Merge: keep sessions from other projects, replace sessions for current project
309
315
  const currentProjectId = projectState.currentProject?.id;
310
316
  if (currentProjectId) {
@@ -315,10 +321,12 @@ export async function reloadSessionsForProject() {
315
321
  } else {
316
322
  sessionState.sessions = sessions;
317
323
  }
324
+ return currentSessionId || null;
318
325
  }
319
326
  } catch (error) {
320
327
  debug.error('session', 'Error reloading sessions:', error);
321
328
  }
329
+ return null;
322
330
  }
323
331
 
324
332
  // ========================================
@@ -345,6 +353,11 @@ function setupCollaborativeListeners() {
345
353
  } else {
346
354
  sessionState.sessions[existingIndex] = session;
347
355
  }
356
+
357
+ // Mark as unread if it's not the current session
358
+ if (session.id !== sessionState.currentSession?.id) {
359
+ markSessionUnread(session.id, session.project_id);
360
+ }
348
361
  });
349
362
 
350
363
  // Listen for session deletion broadcasts from other users
@@ -33,7 +33,8 @@ const defaultSettings: AppSettings = {
33
33
  pushNotifications: false,
34
34
  layoutPresetVisibility: createDefaultPresetVisibility(),
35
35
  allowedBasePaths: [],
36
- fontSize: 13
36
+ fontSize: 13,
37
+ autoUpdate: false
37
38
  };
38
39
 
39
40
  // Create and export reactive settings state directly (starts with defaults)
@@ -0,0 +1,40 @@
1
+ /**
2
+ * WebSocket Connection Status Store
3
+ * Tracks connection state reactively for UI components
4
+ */
5
+
6
+ export type ConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
7
+
8
+ interface ConnectionState {
9
+ status: ConnectionStatus;
10
+ reconnectAttempts: number;
11
+ /** Whether we just reconnected (for showing brief "Reconnected" message) */
12
+ justReconnected: boolean;
13
+ }
14
+
15
+ export const connectionState = $state<ConnectionState>({
16
+ status: 'connected',
17
+ reconnectAttempts: 0,
18
+ justReconnected: false,
19
+ });
20
+
21
+ let reconnectedTimeout: ReturnType<typeof setTimeout> | null = null;
22
+
23
+ export function setConnectionStatus(status: ConnectionStatus, reconnectAttempts = 0): void {
24
+ const wasDisconnected = connectionState.status === 'disconnected' || connectionState.status === 'reconnecting';
25
+ const isNowConnected = status === 'connected';
26
+
27
+ connectionState.status = status;
28
+ connectionState.reconnectAttempts = reconnectAttempts;
29
+
30
+ // Show "Reconnected" briefly when recovering from a disconnection
31
+ if (wasDisconnected && isNowConnected) {
32
+ connectionState.justReconnected = true;
33
+
34
+ if (reconnectedTimeout) clearTimeout(reconnectedTimeout);
35
+ reconnectedTimeout = setTimeout(() => {
36
+ connectionState.justReconnected = false;
37
+ reconnectedTimeout = null;
38
+ }, 2000);
39
+ }
40
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Update Status Store
3
+ * Tracks npm package update availability and auto-update state
4
+ */
5
+
6
+ import ws from '$frontend/lib/utils/ws';
7
+ import { settings } from '$frontend/lib/stores/features/settings.svelte';
8
+ import { debug } from '$shared/utils/logger';
9
+
10
+ interface UpdateState {
11
+ currentVersion: string;
12
+ latestVersion: string;
13
+ updateAvailable: boolean;
14
+ checking: boolean;
15
+ updating: boolean;
16
+ dismissed: boolean;
17
+ error: string | null;
18
+ updateOutput: string | null;
19
+ updateSuccess: boolean;
20
+ }
21
+
22
+ export const updateState = $state<UpdateState>({
23
+ currentVersion: '',
24
+ latestVersion: '',
25
+ updateAvailable: false,
26
+ checking: false,
27
+ updating: false,
28
+ dismissed: false,
29
+ error: null,
30
+ updateOutput: null,
31
+ updateSuccess: false
32
+ });
33
+
34
+ let checkInterval: ReturnType<typeof setInterval> | null = null;
35
+ let successTimeout: ReturnType<typeof setTimeout> | null = null;
36
+
37
+ /** Check for updates from npm registry */
38
+ export async function checkForUpdate(): Promise<void> {
39
+ if (updateState.checking || updateState.updating) return;
40
+
41
+ updateState.checking = true;
42
+ updateState.error = null;
43
+
44
+ try {
45
+ const result = await ws.http('system:check-update', {});
46
+ updateState.currentVersion = result.currentVersion;
47
+ updateState.latestVersion = result.latestVersion;
48
+ updateState.updateAvailable = result.updateAvailable;
49
+
50
+ // Auto-update if enabled and update is available
51
+ if (result.updateAvailable && settings.autoUpdate) {
52
+ debug.log('server', 'Auto-update enabled, starting update...');
53
+ await runUpdate();
54
+ }
55
+ } catch (err) {
56
+ updateState.error = err instanceof Error ? err.message : 'Failed to check for updates';
57
+ debug.error('server', 'Update check failed:', err);
58
+ } finally {
59
+ updateState.checking = false;
60
+ }
61
+ }
62
+
63
+ /** Run the package update */
64
+ export async function runUpdate(): Promise<void> {
65
+ if (updateState.updating) return;
66
+
67
+ updateState.updating = true;
68
+ updateState.error = null;
69
+ updateState.updateOutput = null;
70
+
71
+ try {
72
+ const result = await ws.http('system:run-update', {});
73
+ updateState.updateOutput = result.output;
74
+ updateState.updateSuccess = true;
75
+ updateState.updateAvailable = false;
76
+ updateState.latestVersion = result.newVersion;
77
+
78
+ debug.log('server', 'Update completed successfully');
79
+
80
+ // Clear success message after 5 seconds
81
+ if (successTimeout) clearTimeout(successTimeout);
82
+ successTimeout = setTimeout(() => {
83
+ updateState.updateSuccess = false;
84
+ updateState.dismissed = true;
85
+ }, 5000);
86
+ } catch (err) {
87
+ updateState.error = err instanceof Error ? err.message : 'Update failed';
88
+ debug.error('server', 'Update failed:', err);
89
+ } finally {
90
+ updateState.updating = false;
91
+ }
92
+ }
93
+
94
+ /** Dismiss the update banner */
95
+ export function dismissUpdate(): void {
96
+ updateState.dismissed = true;
97
+ }
98
+
99
+ /** Start periodic update checks (every 30 minutes) */
100
+ export function startUpdateChecker(): void {
101
+ // Initial check after 5 seconds (let the app settle)
102
+ setTimeout(() => {
103
+ checkForUpdate();
104
+ }, 5000);
105
+
106
+ // Periodic check every 30 minutes
107
+ if (checkInterval) clearInterval(checkInterval);
108
+ checkInterval = setInterval(() => {
109
+ updateState.dismissed = false; // Reset dismissal on new checks
110
+ checkForUpdate();
111
+ }, 30 * 60 * 1000);
112
+ }
113
+
114
+ /** Stop periodic update checks */
115
+ export function stopUpdateChecker(): void {
116
+ if (checkInterval) {
117
+ clearInterval(checkInterval);
118
+ checkInterval = null;
119
+ }
120
+ if (successTimeout) {
121
+ clearTimeout(successTimeout);
122
+ successTimeout = null;
123
+ }
124
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { WSClient } from '$shared/utils/ws-client';
8
8
  import type { WSAPI } from '$backend/ws';
9
+ import { setConnectionStatus } from '$frontend/lib/stores/ui/connection.svelte';
9
10
 
10
11
  /**
11
12
  * Get WebSocket URL based on environment
@@ -21,7 +22,10 @@ const ws = new WSClient<WSAPI>(getWebSocketUrl(), {
21
22
  autoReconnect: true,
22
23
  maxReconnectAttempts: 0, // Infinite reconnect
23
24
  reconnectDelay: 1000,
24
- maxReconnectDelay: 30000
25
+ maxReconnectDelay: 30000,
26
+ onStatusChange: (status, reconnectAttempts) => {
27
+ setConnectionStatus(status, reconnectAttempts);
28
+ }
25
29
  });
26
30
 
27
31
  // CRITICAL: Handle Vite HMR to prevent WebSocket connection accumulation
package/index.html CHANGED
@@ -59,7 +59,7 @@
59
59
  </script>
60
60
  </head>
61
61
  <body
62
- class="min-h-screen bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100 transition-colors duration-200"
62
+ class="min-h-dvh bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100 transition-colors duration-200"
63
63
  >
64
64
  <!-- App mount point -->
65
65
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",
@@ -60,6 +60,7 @@
60
60
  "@sveltejs/vite-plugin-svelte": "^6.0.0",
61
61
  "@tailwindcss/vite": "^4.1.11",
62
62
  "@types/bun": "^1.2.18",
63
+ "@types/node": "^24.0.14",
63
64
  "@types/qrcode": "^1.5.6",
64
65
  "@types/xterm": "^3.0.0",
65
66
  "concurrently": "^9.2.1",
@@ -82,18 +83,11 @@
82
83
  "@modelcontextprotocol/sdk": "^1.26.0",
83
84
  "@monaco-editor/loader": "^1.5.0",
84
85
  "@opencode-ai/sdk": "^1.2.15",
85
- "@tailwindcss/typography": "^0.5.16",
86
- "@types/marked": "^5.0.2",
87
- "@types/node": "^24.0.14",
88
86
  "@xterm/addon-fit": "^0.10.0",
89
87
  "@xterm/addon-web-links": "^0.11.0",
90
88
  "bun-pty": "^0.4.2",
91
- "canvas": "^3.1.2",
92
- "chokidar": "^4.0.3",
93
89
  "cloudflared": "^0.7.1",
94
- "devtools-detector": "^2.0.23",
95
90
  "elysia": "^1.4.19",
96
- "eruda": "^3.4.3",
97
91
  "file-type": "^21.0.0",
98
92
  "is-text-path": "^3.0.0",
99
93
  "marked": "^16.1.1",
@@ -102,8 +96,6 @@
102
96
  "puppeteer": "^24.33.0",
103
97
  "puppeteer-cluster": "^0.25.0",
104
98
  "qrcode": "^1.5.4",
105
- "sharp": "^0.34.3",
106
- "werift": "^0.22.2",
107
99
  "xterm": "^5.3.0"
108
100
  },
109
101
  "trustedDependencies": [
@@ -14,4 +14,6 @@ export interface AppSettings {
14
14
  allowedBasePaths: string[];
15
15
  /** Base font size in pixels (10–20). Default: 13. */
16
16
  fontSize: number;
17
+ /** Automatically update to the latest version when available. Default: false. */
18
+ autoUpdate: boolean;
17
19
  }
@@ -37,6 +37,11 @@ const BINARY_ACTIONS = new Set<string>([
37
37
  // Client Options
38
38
  // ============================================================================
39
39
 
40
+ /**
41
+ * Connection status for external consumers
42
+ */
43
+ export type WSConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
44
+
40
45
  /**
41
46
  * WebSocket client options
42
47
  */
@@ -49,6 +54,8 @@ export interface WSClientOptions {
49
54
  reconnectDelay?: number;
50
55
  /** Maximum reconnect delay in ms */
51
56
  maxReconnectDelay?: number;
57
+ /** Callback when connection status changes */
58
+ onStatusChange?: (status: WSConnectionStatus, reconnectAttempts: number) => void;
52
59
  }
53
60
 
54
61
  // ============================================================================
@@ -197,7 +204,7 @@ function decodeBinaryMessage(buffer: ArrayBuffer): { action: string; payload: an
197
204
  export class WSClient<TAPI extends { client: any; server: any }> {
198
205
  private ws: WebSocket | null = null;
199
206
  private url: string;
200
- private options: Required<WSClientOptions>;
207
+ private options: Required<Omit<WSClientOptions, 'onStatusChange'>> & Pick<WSClientOptions, 'onStatusChange'>;
201
208
  private reconnectAttempts = 0;
202
209
  private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
203
210
  private listeners = new Map<string, Set<(payload: any) => void>>();
@@ -226,7 +233,8 @@ export class WSClient<TAPI extends { client: any; server: any }> {
226
233
  autoReconnect: options.autoReconnect ?? true,
227
234
  maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
228
235
  reconnectDelay: options.reconnectDelay ?? 1000,
229
- maxReconnectDelay: options.maxReconnectDelay ?? 30000
236
+ maxReconnectDelay: options.maxReconnectDelay ?? 30000,
237
+ onStatusChange: options.onStatusChange ?? undefined
230
238
  };
231
239
 
232
240
  this.connect();
@@ -262,6 +270,7 @@ export class WSClient<TAPI extends { client: any; server: any }> {
262
270
  debug.log('websocket', 'Connected');
263
271
  this.isConnected = true;
264
272
  this.reconnectAttempts = 0;
273
+ this.options.onStatusChange?.('connected', 0);
265
274
 
266
275
  // Sync context on reconnection - MUST await before flushing queue
267
276
  if (this.context.userId || this.context.projectId) {
@@ -320,7 +329,10 @@ export class WSClient<TAPI extends { client: any; server: any }> {
320
329
 
321
330
  // Auto-reconnect
322
331
  if (this.shouldReconnect && this.options.autoReconnect) {
332
+ this.options.onStatusChange?.('reconnecting', this.reconnectAttempts);
323
333
  this.scheduleReconnect();
334
+ } else {
335
+ this.options.onStatusChange?.('disconnected', this.reconnectAttempts);
324
336
  }
325
337
  };
326
338
  } catch (err) {
@@ -389,6 +401,7 @@ export class WSClient<TAPI extends { client: any; server: any }> {
389
401
  private scheduleReconnect(): void {
390
402
  if (this.options.maxReconnectAttempts > 0 && this.reconnectAttempts >= this.options.maxReconnectAttempts) {
391
403
  debug.error('websocket', 'Max reconnect attempts reached');
404
+ this.options.onStatusChange?.('disconnected', this.reconnectAttempts);
392
405
  return;
393
406
  }
394
407
 
@@ -399,6 +412,7 @@ export class WSClient<TAPI extends { client: any; server: any }> {
399
412
  );
400
413
 
401
414
  debug.log('websocket', `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
415
+ this.options.onStatusChange?.('reconnecting', this.reconnectAttempts);
402
416
 
403
417
  this.reconnectTimeout = setTimeout(() => {
404
418
  this.connect();
package/vite.config.ts CHANGED
@@ -12,6 +12,7 @@ export default defineConfig({
12
12
  server: {
13
13
  port: frontendPort,
14
14
  strictPort: false,
15
+ allowedHosts: true,
15
16
  proxy: {
16
17
  '/api': {
17
18
  target: `http://localhost:${backendPort}`,