@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.
- package/backend/ws/system/operations.ts +95 -0
- 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/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 +1 -1
- package/shared/types/stores/settings.ts +2 -0
- package/shared/utils/ws-client.ts +16 -2
- package/vite.config.ts +1 -0
|
@@ -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({}),
|
package/frontend/App.svelte
CHANGED
|
@@ -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
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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-
|
|
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} (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-[
|
|
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:
|
|
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
|
-
<
|
|
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-
|
|
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
|
-
* -
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
//
|
|
109
|
-
return 'bg-
|
|
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
|
-
|
|
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
|
+
}
|
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.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",
|
|
@@ -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();
|