@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.
- package/README.md +7 -1
- package/backend/ws/system/operations.ts +95 -0
- package/bin/clopen.ts +89 -0
- package/bun.lock +5 -203
- package/frontend/App.svelte +24 -7
- package/frontend/lib/components/chat/ChatInterface.svelte +2 -2
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +1 -1
- package/frontend/lib/components/common/ConnectionBanner.svelte +55 -0
- package/frontend/lib/components/common/UpdateBanner.svelte +88 -0
- package/frontend/lib/components/files/FileTree.svelte +34 -23
- package/frontend/lib/components/history/HistoryModal.svelte +5 -0
- package/frontend/lib/components/settings/SettingsModal.svelte +2 -2
- package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
- package/frontend/lib/components/settings/general/UpdateSettings.svelte +123 -0
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +1 -1
- package/frontend/lib/stores/core/app.svelte.ts +47 -0
- package/frontend/lib/stores/core/presence.svelte.ts +30 -13
- package/frontend/lib/stores/core/projects.svelte.ts +10 -2
- package/frontend/lib/stores/core/sessions.svelte.ts +15 -2
- package/frontend/lib/stores/features/settings.svelte.ts +2 -1
- package/frontend/lib/stores/ui/connection.svelte.ts +40 -0
- package/frontend/lib/stores/ui/update.svelte.ts +124 -0
- package/frontend/lib/utils/ws.ts +5 -1
- package/index.html +1 -1
- package/package.json +2 -10
- package/shared/types/stores/settings.ts +2 -0
- package/shared/utils/ws-client.ts +16 -2
- 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
|
+
}
|
package/frontend/lib/utils/ws.ts
CHANGED
|
@@ -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-
|
|
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.
|
|
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": [
|
|
@@ -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();
|