@myrialabs/clopen 0.1.5 → 0.1.6

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.
@@ -3,14 +3,109 @@
3
3
  *
4
4
  * HTTP endpoints for system-level operations:
5
5
  * - Clear all database data
6
+ * - Check for package updates
7
+ * - Run package update
6
8
  */
7
9
 
8
10
  import { t } from 'elysia';
11
+ import { join } from 'node:path';
12
+ import { readFileSync } from 'node:fs';
9
13
  import { createRouter } from '$shared/utils/ws-server';
10
14
  import { initializeDatabase, getDatabase } from '../../lib/database';
11
15
  import { debug } from '$shared/utils/logger';
12
16
 
17
+ /** Read current version from package.json */
18
+ function getCurrentVersion(): string {
19
+ try {
20
+ const packagePath = join(import.meta.dir, '..', '..', '..', 'package.json');
21
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
22
+ return pkg.version || '0.0.0';
23
+ } catch {
24
+ return '0.0.0';
25
+ }
26
+ }
27
+
28
+ /** Fetch latest version from npm registry */
29
+ async function fetchLatestVersion(): Promise<string> {
30
+ const response = await fetch('https://registry.npmjs.org/@myrialabs/clopen/latest');
31
+ if (!response.ok) {
32
+ throw new Error(`npm registry returned ${response.status}`);
33
+ }
34
+ const data = await response.json() as { version: string };
35
+ return data.version;
36
+ }
37
+
38
+ /** Simple semver comparison: returns true if latest > current */
39
+ function isNewerVersion(current: string, latest: string): boolean {
40
+ const currentParts = current.split('.').map(Number);
41
+ const latestParts = latest.split('.').map(Number);
42
+
43
+ for (let i = 0; i < 3; i++) {
44
+ const c = currentParts[i] || 0;
45
+ const l = latestParts[i] || 0;
46
+ if (l > c) return true;
47
+ if (l < c) return false;
48
+ }
49
+ return false;
50
+ }
51
+
13
52
  export const operationsHandler = createRouter()
53
+ // Check for package updates
54
+ .http('system:check-update', {
55
+ data: t.Object({}),
56
+ response: t.Object({
57
+ currentVersion: t.String(),
58
+ latestVersion: t.String(),
59
+ updateAvailable: t.Boolean()
60
+ })
61
+ }, async () => {
62
+ const currentVersion = getCurrentVersion();
63
+ debug.log('server', `Checking for updates... current version: ${currentVersion}`);
64
+
65
+ const latestVersion = await fetchLatestVersion();
66
+ const updateAvailable = isNewerVersion(currentVersion, latestVersion);
67
+
68
+ debug.log('server', `Latest version: ${latestVersion}, update available: ${updateAvailable}`);
69
+
70
+ return { currentVersion, latestVersion, updateAvailable };
71
+ })
72
+
73
+ // Run package update
74
+ .http('system:run-update', {
75
+ data: t.Object({}),
76
+ response: t.Object({
77
+ success: t.Boolean(),
78
+ output: t.String(),
79
+ newVersion: t.String()
80
+ })
81
+ }, async () => {
82
+ debug.log('server', 'Running package update...');
83
+
84
+ const proc = Bun.spawn(['bun', 'add', '-g', '@myrialabs/clopen@latest'], {
85
+ stdout: 'pipe',
86
+ stderr: 'pipe'
87
+ });
88
+
89
+ const [stdout, stderr] = await Promise.all([
90
+ new Response(proc.stdout).text(),
91
+ new Response(proc.stderr).text()
92
+ ]);
93
+
94
+ const exitCode = await proc.exited;
95
+ const output = (stdout + '\n' + stderr).trim();
96
+
97
+ if (exitCode !== 0) {
98
+ throw new Error(`Update failed (exit code ${exitCode}): ${output}`);
99
+ }
100
+
101
+ // Re-fetch to confirm new version
102
+ const newVersion = await fetchLatestVersion();
103
+
104
+ debug.log('server', `Update completed. New version: ${newVersion}`);
105
+
106
+ return { success: true, output, newVersion };
107
+ })
108
+
14
109
  // Clear all database data
15
110
  .http('system:clear-data', {
16
111
  data: t.Object({}),
@@ -1,10 +1,13 @@
1
1
  <script lang="ts">
2
- import { onMount } from 'svelte';
2
+ import { onMount, onDestroy } from 'svelte';
3
3
  import WorkspaceLayout from '$frontend/lib/components/workspace/WorkspaceLayout.svelte';
4
+ import ConnectionBanner from '$frontend/lib/components/common/ConnectionBanner.svelte';
5
+ import UpdateBanner from '$frontend/lib/components/common/UpdateBanner.svelte';
4
6
  import { backgroundTerminalService } from '$frontend/lib/services/terminal/background';
5
7
  import { initializeMCPPreview } from '$frontend/lib/services/preview';
6
8
  import { globalStreamMonitor } from '$frontend/lib/services/notification/global-stream-monitor';
7
9
  import { tunnelStore } from '$frontend/lib/stores/features/tunnel.svelte';
10
+ import { startUpdateChecker, stopUpdateChecker } from '$frontend/lib/stores/ui/update.svelte';
8
11
 
9
12
  // NOTE: In Phase 3, we'll need to handle routing for SPA
10
13
  // For now, we'll just render the main workspace
@@ -27,12 +30,26 @@
27
30
 
28
31
  // Restore tunnel status from server
29
32
  tunnelStore.checkStatus();
33
+
34
+ // Start periodic update checker
35
+ startUpdateChecker();
36
+ });
37
+
38
+ onDestroy(() => {
39
+ stopUpdateChecker();
30
40
  });
31
41
  </script>
32
42
 
33
- <WorkspaceLayout>
34
- {#snippet children()}
35
- <!-- Main content will be here -->
36
- <!-- TODO: Add SPA router in Phase 3 if needed -->
37
- {/snippet}
38
- </WorkspaceLayout>
43
+ <div class="flex flex-col h-dvh w-screen overflow-hidden">
44
+ <ConnectionBanner />
45
+ <UpdateBanner />
46
+
47
+ <div class="flex-1 min-h-0">
48
+ <WorkspaceLayout>
49
+ {#snippet children()}
50
+ <!-- Main content will be here -->
51
+ <!-- TODO: Add SPA router in Phase 3 if needed -->
52
+ {/snippet}
53
+ </WorkspaceLayout>
54
+ </div>
55
+ </div>
@@ -12,7 +12,7 @@
12
12
  <script lang="ts">
13
13
  import { sessionState, setCurrentSession, createNewChatSession, clearMessages, loadMessagesForSession } from '$frontend/lib/stores/core/sessions.svelte';
14
14
  import { projectState } from '$frontend/lib/stores/core/projects.svelte';
15
- import { appState } from '$frontend/lib/stores/core/app.svelte';
15
+ import { appState, isSessionUnread } from '$frontend/lib/stores/core/app.svelte';
16
16
  import { presenceState, isSessionWaitingInput } from '$frontend/lib/stores/core/presence.svelte';
17
17
  import { userStore } from '$frontend/lib/stores/features/user.svelte';
18
18
  import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
@@ -294,7 +294,7 @@
294
294
  >
295
295
  <div class="flex-1 min-w-0">
296
296
  <div class="flex items-center gap-1.5">
297
- <span class="w-1.5 h-1.5 rounded-full shrink-0 {isStreaming ? (isSessionWaitingInput(session.id, projectState.currentProject?.id) ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500 animate-pulse') : isCurrent ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'}"></span>
297
+ <span class="w-1.5 h-1.5 rounded-full shrink-0 {isStreaming ? (isSessionWaitingInput(session.id, projectState.currentProject?.id) ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500 animate-pulse') : isCurrent ? 'bg-green-500' : isSessionUnread(session.id) ? 'bg-blue-500' : 'bg-slate-300 dark:bg-slate-600'}"></span>
298
298
  <span class="text-sm font-medium truncate">{getSessionShortTitle(session)}</span>
299
299
  </div>
300
300
  <p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
@@ -519,7 +519,7 @@ This will restore your files to this point within this session.`
519
519
  <!-- Diff View -->
520
520
  {#if expandedDiffs.has(conflict.filepath) && conflict.restoreContent && conflict.currentContent}
521
521
  <div class="mb-3">
522
- <div class="flex items-center gap-3 mb-1.5 text-[10px] text-slate-500 dark:text-slate-400">
522
+ <div class="flex items-center gap-3 mb-1.5 text-3xs text-slate-500 dark:text-slate-400">
523
523
  <span class="flex items-center gap-1">
524
524
  <span class="inline-block w-2 h-2 rounded-sm bg-red-400"></span>
525
525
  Restore version
@@ -0,0 +1,55 @@
1
+ <script lang="ts">
2
+ import { connectionState } from '$frontend/lib/stores/ui/connection.svelte';
3
+ import Icon from '$frontend/lib/components/common/Icon.svelte';
4
+ import ws from '$frontend/lib/utils/ws';
5
+ import { slide } from 'svelte/transition';
6
+
7
+ const showBanner = $derived(
8
+ connectionState.status !== 'connected' || connectionState.justReconnected
9
+ );
10
+
11
+ const isReconnecting = $derived(connectionState.status === 'reconnecting');
12
+ const isDisconnected = $derived(connectionState.status === 'disconnected');
13
+ const isReconnected = $derived(connectionState.justReconnected && connectionState.status === 'connected');
14
+
15
+ function handleReconnect() {
16
+ ws.reconnect();
17
+ }
18
+ </script>
19
+
20
+ {#if showBanner}
21
+ <div
22
+ transition:slide={{ duration: 300 }}
23
+ class="flex items-center justify-center gap-2 px-4 py-1.5 text-sm font-medium
24
+ {isReconnected
25
+ ? 'bg-emerald-600 text-white'
26
+ : isDisconnected
27
+ ? 'bg-red-600 text-white'
28
+ : 'bg-amber-600 text-white'}"
29
+ role="status"
30
+ aria-live="polite"
31
+ >
32
+ {#if isReconnected}
33
+ <Icon name="lucide:wifi" class="w-4 h-4" />
34
+ <span>Reconnected</span>
35
+ {:else if isReconnecting}
36
+ <Icon name="lucide:loader-circle" class="w-4 h-4 animate-spin" />
37
+ <span>Reconnecting{#if connectionState.reconnectAttempts > 1}&nbsp;(attempt {connectionState.reconnectAttempts}){/if}...</span>
38
+ <button
39
+ onclick={handleReconnect}
40
+ class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
41
+ >
42
+ Reconnect now
43
+ </button>
44
+ {:else}
45
+ <Icon name="lucide:wifi-off" class="w-4 h-4" />
46
+ <span>Connection lost</span>
47
+ <button
48
+ onclick={handleReconnect}
49
+ class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
50
+ >
51
+ Reconnect now
52
+ </button>
53
+ {/if}
54
+ </div>
55
+ {/if}
@@ -0,0 +1,88 @@
1
+ <script lang="ts">
2
+ import { updateState, runUpdate, dismissUpdate, checkForUpdate } from '$frontend/lib/stores/ui/update.svelte';
3
+ import { settings, updateSettings } from '$frontend/lib/stores/features/settings.svelte';
4
+ import Icon from '$frontend/lib/components/common/Icon.svelte';
5
+ import { slide } from 'svelte/transition';
6
+
7
+ const showBanner = $derived(
8
+ !updateState.dismissed && (
9
+ updateState.updateAvailable ||
10
+ updateState.updating ||
11
+ updateState.updateSuccess ||
12
+ updateState.error
13
+ )
14
+ );
15
+
16
+ function handleUpdate() {
17
+ runUpdate();
18
+ }
19
+
20
+ function handleDismiss() {
21
+ dismissUpdate();
22
+ }
23
+
24
+ function toggleAutoUpdate() {
25
+ updateSettings({ autoUpdate: !settings.autoUpdate });
26
+ }
27
+
28
+ function handleRetry() {
29
+ checkForUpdate();
30
+ }
31
+ </script>
32
+
33
+ {#if showBanner}
34
+ <div
35
+ transition:slide={{ duration: 300 }}
36
+ class="flex items-center justify-center gap-2 px-4 py-1.5 text-sm font-medium
37
+ {updateState.updateSuccess
38
+ ? 'bg-emerald-600 text-white'
39
+ : updateState.error
40
+ ? 'bg-red-600 text-white'
41
+ : updateState.updating
42
+ ? 'bg-amber-600 text-white'
43
+ : 'bg-violet-600 text-white'}"
44
+ role="status"
45
+ aria-live="polite"
46
+ >
47
+ {#if updateState.updateSuccess}
48
+ <Icon name="lucide:package-check" class="w-4 h-4" />
49
+ <span>Updated to v{updateState.latestVersion} — restart clopen to apply</span>
50
+ {:else if updateState.error}
51
+ <Icon name="lucide:package-x" class="w-4 h-4" />
52
+ <span>Update failed</span>
53
+ <button
54
+ onclick={handleRetry}
55
+ class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
56
+ >
57
+ Retry
58
+ </button>
59
+ <button
60
+ onclick={handleDismiss}
61
+ class="ml-1 px-1.5 py-0.5 text-xs rounded bg-white/10 hover:bg-white/20 transition-colors"
62
+ >
63
+ <Icon name="lucide:x" class="w-3 h-3" />
64
+ </button>
65
+ {:else if updateState.updating}
66
+ <Icon name="lucide:loader-circle" class="w-4 h-4 animate-spin" />
67
+ <span>Updating to v{updateState.latestVersion}...</span>
68
+ {:else}
69
+ <Icon name="lucide:package" class="w-4 h-4" />
70
+ <span>
71
+ v{updateState.latestVersion} available
72
+ <span class="opacity-70">(current: v{updateState.currentVersion})</span>
73
+ </span>
74
+ <button
75
+ onclick={handleUpdate}
76
+ class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
77
+ >
78
+ Update now
79
+ </button>
80
+ <button
81
+ onclick={handleDismiss}
82
+ class="ml-1 px-1.5 py-0.5 text-xs rounded bg-white/10 hover:bg-white/20 transition-colors"
83
+ >
84
+ <Icon name="lucide:x" class="w-3 h-3" />
85
+ </button>
86
+ {/if}
87
+ </div>
88
+ {/if}
@@ -11,6 +11,7 @@
11
11
  import Modal from '$frontend/lib/components/common/Modal.svelte';
12
12
  import Dialog from '$frontend/lib/components/common/Dialog.svelte';
13
13
  import { presenceState, isSessionWaitingInput } from '$frontend/lib/stores/core/presence.svelte';
14
+ import { isSessionUnread } from '$frontend/lib/stores/core/app.svelte';
14
15
  import { userStore } from '$frontend/lib/stores/features/user.svelte';
15
16
  import { debug } from '$shared/utils/logger';
16
17
 
@@ -475,6 +476,10 @@
475
476
  <span
476
477
  class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-900 {isSessionWaitingInput(session.id, projectState.currentProject?.id) ? 'bg-amber-500' : 'bg-emerald-500'}"
477
478
  ></span>
479
+ {:else if isSessionUnread(session.id)}
480
+ <span
481
+ class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-900 bg-blue-500"
482
+ ></span>
478
483
  {:else}
479
484
  <span
480
485
  class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-900 bg-slate-300 dark:bg-slate-600"
@@ -83,7 +83,7 @@
83
83
  role="dialog"
84
84
  aria-labelledby="settings-title"
85
85
  tabindex="-1"
86
- class="flex flex-col w-full max-w-225 h-[85vh] 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-screen max-md:max-h-screen max-md:rounded-none"
86
+ 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"
87
87
  onclick={(e) => e.stopPropagation()}
88
88
  onkeydown={(e) => e.stopPropagation()}
89
89
  in:scale={{ duration: 250, easing: cubicOut, start: 0.95 }}
@@ -100,7 +100,7 @@
100
100
  onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
101
101
  aria-label="Toggle menu"
102
102
  >
103
- <Icon name={isMobileMenuOpen ? 'lucide:x' : 'lucide:menu'} class="w-5 h-5" />
103
+ <Icon name={isMobileMenuOpen ? 'lucide:arrow-left' : 'lucide:menu'} class="w-5 h-5" />
104
104
  </button>
105
105
  <h2
106
106
  id="settings-title"
@@ -1,9 +1,14 @@
1
1
  <script lang="ts">
2
2
  import DataManagementSettings from './DataManagementSettings.svelte';
3
3
  import AdvancedSettings from './AdvancedSettings.svelte';
4
+ import UpdateSettings from './UpdateSettings.svelte';
4
5
  </script>
5
6
 
6
- <DataManagementSettings />
7
+ <UpdateSettings />
8
+
9
+ <div class="mt-6">
10
+ <DataManagementSettings />
11
+ </div>
7
12
 
8
13
  <div class="mt-6">
9
14
  <AdvancedSettings />
@@ -0,0 +1,123 @@
1
+ <script lang="ts">
2
+ import { settings, updateSettings } from '$frontend/lib/stores/features/settings.svelte';
3
+ import { updateState, checkForUpdate, runUpdate } from '$frontend/lib/stores/ui/update.svelte';
4
+ import Icon from '../../common/Icon.svelte';
5
+
6
+ function toggleAutoUpdate() {
7
+ updateSettings({ autoUpdate: !settings.autoUpdate });
8
+ }
9
+
10
+ function handleCheckNow() {
11
+ checkForUpdate();
12
+ }
13
+
14
+ function handleUpdateNow() {
15
+ runUpdate();
16
+ }
17
+ </script>
18
+
19
+ <div class="py-1">
20
+ <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Updates</h3>
21
+ <p class="text-sm text-slate-600 dark:text-slate-500 mb-5">Check for new versions and configure automatic updates</p>
22
+
23
+ <div class="flex flex-col gap-3.5">
24
+ <!-- Version Info -->
25
+ <div class="p-4 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-xl">
26
+ <div class="flex items-start gap-3.5">
27
+ <div class="flex items-center justify-center w-10 h-10 rounded-lg shrink-0 bg-violet-400/15 text-violet-500">
28
+ <Icon name="lucide:package" class="w-5 h-5" />
29
+ </div>
30
+ <div class="flex flex-col gap-1 min-w-0 flex-1">
31
+ <div class="text-sm font-semibold text-slate-900 dark:text-slate-100">
32
+ @myrialabs/clopen
33
+ </div>
34
+ <div class="text-xs text-slate-600 dark:text-slate-500">
35
+ {#if updateState.currentVersion}
36
+ Current version: <span class="font-mono font-medium text-slate-700 dark:text-slate-400">v{updateState.currentVersion}</span>
37
+ {:else}
38
+ Version info will appear after checking
39
+ {/if}
40
+ </div>
41
+ {#if updateState.updateAvailable}
42
+ <div class="text-xs">
43
+ <span class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-violet-500/15 text-violet-600 dark:text-violet-400 rounded text-2xs font-semibold">
44
+ v{updateState.latestVersion} available
45
+ </span>
46
+ </div>
47
+ {/if}
48
+ </div>
49
+
50
+ <div class="flex items-center gap-2 shrink-0">
51
+ {#if updateState.updateAvailable}
52
+ <button
53
+ type="button"
54
+ onclick={handleUpdateNow}
55
+ disabled={updateState.updating}
56
+ class="inline-flex items-center gap-1.5 py-2 px-3.5 bg-violet-500/10 border border-violet-500/20 rounded-lg text-violet-600 dark:text-violet-400 text-xs font-semibold cursor-pointer transition-all duration-150 hover:bg-violet-500/20 hover:border-violet-600/40 disabled:opacity-60 disabled:cursor-not-allowed"
57
+ >
58
+ {#if updateState.updating}
59
+ <div class="w-3.5 h-3.5 border-2 border-violet-600/30 border-t-violet-600 rounded-full animate-spin"></div>
60
+ Updating...
61
+ {:else}
62
+ <Icon name="lucide:download" class="w-3.5 h-3.5" />
63
+ Update
64
+ {/if}
65
+ </button>
66
+ {/if}
67
+ <button
68
+ type="button"
69
+ onclick={handleCheckNow}
70
+ disabled={updateState.checking}
71
+ class="inline-flex items-center gap-1.5 py-2 px-3.5 bg-slate-200/80 dark:bg-slate-700/80 border border-slate-300 dark:border-slate-600 rounded-lg text-slate-700 dark:text-slate-300 text-xs font-semibold cursor-pointer transition-all duration-150 hover:bg-slate-300 dark:hover:bg-slate-600 disabled:opacity-60 disabled:cursor-not-allowed"
72
+ >
73
+ {#if updateState.checking}
74
+ <div class="w-3.5 h-3.5 border-2 border-slate-600/30 border-t-slate-600 dark:border-slate-400/30 dark:border-t-slate-400 rounded-full animate-spin"></div>
75
+ Checking...
76
+ {:else}
77
+ <Icon name="lucide:refresh-cw" class="w-3.5 h-3.5" />
78
+ Check now
79
+ {/if}
80
+ </button>
81
+ </div>
82
+ </div>
83
+
84
+ {#if updateState.error}
85
+ <div class="mt-3 flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg">
86
+ <Icon name="lucide:circle-alert" class="w-4 h-4 text-red-500 shrink-0" />
87
+ <span class="text-xs text-red-600 dark:text-red-400">{updateState.error}</span>
88
+ </div>
89
+ {/if}
90
+
91
+ {#if updateState.updateSuccess}
92
+ <div class="mt-3 flex items-center gap-2 px-3 py-2 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
93
+ <Icon name="lucide:circle-check" class="w-4 h-4 text-emerald-500 shrink-0" />
94
+ <span class="text-xs text-emerald-600 dark:text-emerald-400">Updated successfully — restart clopen to apply</span>
95
+ </div>
96
+ {/if}
97
+ </div>
98
+
99
+ <!-- Auto-Update Toggle -->
100
+ <div class="flex items-center justify-between gap-4 py-3 px-4 bg-slate-100/50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 rounded-lg">
101
+ <div class="flex items-center gap-3">
102
+ <Icon name="lucide:refresh-cw" class="w-4.5 h-4.5 text-slate-600 dark:text-slate-400" />
103
+ <div class="text-left">
104
+ <div class="text-sm font-medium text-slate-900 dark:text-slate-100">Auto-update</div>
105
+ <div class="text-xs text-slate-600 dark:text-slate-400">Automatically install new versions when available</div>
106
+ </div>
107
+ </div>
108
+ <button
109
+ type="button"
110
+ role="switch"
111
+ aria-checked={settings.autoUpdate}
112
+ onclick={toggleAutoUpdate}
113
+ class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500/30
114
+ {settings.autoUpdate ? 'bg-violet-600' : 'bg-slate-300 dark:bg-slate-600'}"
115
+ >
116
+ <span
117
+ class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out
118
+ {settings.autoUpdate ? 'translate-x-5' : 'translate-x-0'}"
119
+ ></span>
120
+ </button>
121
+ </div>
122
+ </div>
123
+ </div>
@@ -138,7 +138,7 @@
138
138
 
139
139
  <!-- Main Workspace Layout -->
140
140
  <div
141
- class="h-screen w-screen overflow-hidden {isMobile ? 'bg-white/90 dark:bg-slate-900/98' : 'bg-slate-50 dark:bg-slate-900/70'} text-slate-900 dark:text-slate-100 font-sans"
141
+ class="h-full w-full overflow-hidden {isMobile ? 'bg-white/90 dark:bg-slate-900/98' : 'bg-slate-50 dark:bg-slate-900/70'} text-slate-900 dark:text-slate-100 font-sans"
142
142
  >
143
143
  <!-- Skip link for accessibility -->
144
144
  <a
@@ -50,6 +50,9 @@ interface AppState {
50
50
  // Per-session process states (source of truth for multi-session support)
51
51
  sessionStates: Record<string, SessionProcessState>;
52
52
 
53
+ // Unread sessions — maps session ID → project ID for sessions with new activity
54
+ unreadSessions: Map<string, string>;
55
+
53
56
  // Page Information
54
57
  pageInfo: PageInfo;
55
58
 
@@ -71,6 +74,9 @@ export const appState = $state<AppState>({
71
74
  // Per-session process states
72
75
  sessionStates: {},
73
76
 
77
+ // Unread sessions (sessionId → projectId)
78
+ unreadSessions: new Map<string, string>(),
79
+
74
80
  // Page Information
75
81
  pageInfo: {
76
82
  title: 'Claude Code',
@@ -129,6 +135,47 @@ export function clearSessionProcessState(sessionId: string): void {
129
135
  delete appState.sessionStates[sessionId];
130
136
  }
131
137
 
138
+ // ========================================
139
+ // UNREAD SESSION MANAGEMENT
140
+ // ========================================
141
+
142
+ /**
143
+ * Mark a session as unread (has new activity the user hasn't seen).
144
+ */
145
+ export function markSessionUnread(sessionId: string, projectId: string): void {
146
+ const next = new Map(appState.unreadSessions);
147
+ next.set(sessionId, projectId);
148
+ appState.unreadSessions = next;
149
+ }
150
+
151
+ /**
152
+ * Mark a session as read (user has viewed it).
153
+ */
154
+ export function markSessionRead(sessionId: string): void {
155
+ if (appState.unreadSessions.has(sessionId)) {
156
+ const next = new Map(appState.unreadSessions);
157
+ next.delete(sessionId);
158
+ appState.unreadSessions = next;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Check if a session is unread.
164
+ */
165
+ export function isSessionUnread(sessionId: string): boolean {
166
+ return appState.unreadSessions.has(sessionId);
167
+ }
168
+
169
+ /**
170
+ * Check if a project has any unread sessions.
171
+ */
172
+ export function hasUnreadSessionsForProject(projectId: string): boolean {
173
+ for (const pId of appState.unreadSessions.values()) {
174
+ if (pId === projectId) return true;
175
+ }
176
+ return false;
177
+ }
178
+
132
179
  // ========================================
133
180
  // UI STATE MANAGEMENT
134
181
  // ========================================
@@ -9,7 +9,8 @@
9
9
 
10
10
  import { projectStatusService, type ProjectStatus } from '$frontend/lib/services/project/status.service';
11
11
  import { userStore } from '$frontend/lib/stores/features/user.svelte';
12
- import { appState } from '$frontend/lib/stores/core/app.svelte';
12
+ import { appState, markSessionUnread, hasUnreadSessionsForProject } from '$frontend/lib/stores/core/app.svelte';
13
+ import { sessionState } from '$frontend/lib/stores/core/sessions.svelte';
13
14
 
14
15
  // Shared reactive state
15
16
  export const presenceState = $state<{
@@ -30,6 +31,7 @@ export function initPresence() {
30
31
 
31
32
  projectStatusService.onStatusUpdate((statuses) => {
32
33
  const currentUserId = userStore.currentUser?.id;
34
+ const currentSessionId = sessionState.currentSession?.id;
33
35
  const statusMap = new Map<string, ProjectStatus>();
34
36
  statuses.forEach((status) => {
35
37
  statusMap.set(status.projectId, {
@@ -38,6 +40,15 @@ export function initPresence() {
38
40
  ? status.activeUsers.filter((u) => u.userId !== currentUserId)
39
41
  : status.activeUsers
40
42
  });
43
+
44
+ // Mark non-current sessions with active streams as unread
45
+ if (status.streams) {
46
+ for (const stream of status.streams) {
47
+ if (stream.status === 'active' && stream.chatSessionId !== currentSessionId) {
48
+ markSessionUnread(stream.chatSessionId, status.projectId);
49
+ }
50
+ }
51
+ }
41
52
  });
42
53
  presenceState.statuses = statusMap;
43
54
  });
@@ -84,27 +95,33 @@ export function isSessionWaitingInput(chatSessionId: string, projectId?: string)
84
95
 
85
96
  /**
86
97
  * Get the status indicator color for a project.
87
- * - Gray (bg-slate-500/30) : no active streams (idle)
88
98
  * Priority (highest wins when multiple sessions exist):
89
99
  * - Green (bg-emerald-500) : at least one stream actively processing
90
100
  * - Amber (bg-amber-500) : all active streams waiting for user input
91
- * - Gray (bg-slate-500/30) : no active streams (idle)
101
+ * - Blue (bg-blue-500) : session(s) with unread activity
102
+ * - Gray (bg-slate-500/30) : idle
92
103
  *
93
104
  * Merges backend presence with frontend app state for accuracy.
94
105
  */
95
106
  export function getProjectStatusColor(projectId: string): string {
96
107
  const status = presenceState.statuses.get(projectId);
97
- if (!status?.streams) return 'bg-slate-500/30';
98
108
 
99
- const activeStreams = status.streams.filter((s: any) => s.status === 'active');
100
- if (activeStreams.length === 0) return 'bg-slate-500/30';
109
+ const activeStreams = status?.streams?.filter((s: any) => s.status === 'active') ?? [];
110
+
111
+ if (activeStreams.length > 0) {
112
+ // Green wins: at least one stream is actively processing (not waiting)
113
+ const hasProcessing = activeStreams.some((s: any) =>
114
+ !s.isWaitingInput && !appState.sessionStates[s.chatSessionId]?.isWaitingInput
115
+ );
116
+ if (hasProcessing) return 'bg-emerald-500';
101
117
 
102
- // Green wins: at least one stream is actively processing (not waiting)
103
- const hasProcessing = activeStreams.some((s: any) =>
104
- !s.isWaitingInput && !appState.sessionStates[s.chatSessionId]?.isWaitingInput
105
- );
106
- if (hasProcessing) return 'bg-emerald-500';
118
+ // All active streams are waiting for input
119
+ return 'bg-amber-500';
120
+ }
107
121
 
108
- // All active streams are waiting for input
109
- return 'bg-amber-500';
122
+ // Check for unread sessions in this project
123
+ if (hasUnreadSessionsForProject(projectId)) return 'bg-blue-500';
124
+
125
+ return 'bg-slate-500/30';
110
126
  }
127
+
@@ -109,14 +109,22 @@ export async function setCurrentProject(project: Project | null) {
109
109
 
110
110
  // Reload all sessions for this project from server
111
111
  // (local state may only have sessions from the previous project)
112
- await reloadSessionsForProject();
112
+ const savedSessionId = await reloadSessionsForProject();
113
113
 
114
114
  // Check if there's an existing session for this project
115
115
  const existingSessions = getSessionsForProject(project.id);
116
116
  const activeSessions = existingSessions
117
117
  .filter(s => !s.ended_at)
118
118
  .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
119
- const activeSession = activeSessions[0] || null;
119
+
120
+ // Try server-saved session first (preserves user's last selected session)
121
+ let activeSession = savedSessionId
122
+ ? activeSessions.find(s => s.id === savedSessionId) || null
123
+ : null;
124
+ // Fall back to most recent active session
125
+ if (!activeSession) {
126
+ activeSession = activeSessions[0] || null;
127
+ }
120
128
 
121
129
  if (activeSession) {
122
130
  // Restore the most recent active session for this project
@@ -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.6",
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",
@@ -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}`,