@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
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}
|
|
@@ -222,19 +222,14 @@
|
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
performSearch();
|
|
234
|
-
}
|
|
235
|
-
}, 100);
|
|
236
|
-
}
|
|
237
|
-
// Don't clear search state on close - preserve inputs
|
|
225
|
+
function switchToSearch() {
|
|
226
|
+
searchVisible = true;
|
|
227
|
+
setTimeout(() => {
|
|
228
|
+
searchInputRef?.focus();
|
|
229
|
+
if (searchQuery.trim()) {
|
|
230
|
+
performSearch();
|
|
231
|
+
}
|
|
232
|
+
}, 100);
|
|
238
233
|
}
|
|
239
234
|
|
|
240
235
|
// Search functions
|
|
@@ -454,15 +449,7 @@
|
|
|
454
449
|
<Icon name="lucide:folder-plus" class="w-4 h-4" />
|
|
455
450
|
</button>
|
|
456
451
|
{/if}
|
|
457
|
-
|
|
458
|
-
<button
|
|
459
|
-
class="flex flex-shrink-0 p-1.5 rounded-md transition-colors {searchVisible ? 'text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-900/30' : 'text-slate-600 dark:text-slate-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/30'}"
|
|
460
|
-
onclick={toggleSearch}
|
|
461
|
-
title="Search"
|
|
462
|
-
>
|
|
463
|
-
<Icon name="lucide:search" class="w-4 h-4" />
|
|
464
|
-
</button>
|
|
465
|
-
{#if hasClipboard && onPasteToRoot}
|
|
452
|
+
{#if hasClipboard && onPasteToRoot}
|
|
466
453
|
<button
|
|
467
454
|
class="flex flex-shrink-0 p-1.5 text-slate-600 dark:text-slate-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/30 rounded-md transition-colors"
|
|
468
455
|
onclick={onPasteToRoot}
|
|
@@ -475,7 +462,31 @@
|
|
|
475
462
|
</div>
|
|
476
463
|
</div>
|
|
477
464
|
|
|
478
|
-
<!--
|
|
465
|
+
<!-- Tab Navigation -->
|
|
466
|
+
<div class="relative flex border-b border-slate-200 dark:border-slate-700">
|
|
467
|
+
<button
|
|
468
|
+
onclick={() => { searchVisible = false; }}
|
|
469
|
+
class="relative flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors {!searchVisible ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
|
|
470
|
+
>
|
|
471
|
+
<Icon name="lucide:folder" class="w-3.5 h-3.5" />
|
|
472
|
+
Explorer
|
|
473
|
+
{#if !searchVisible}
|
|
474
|
+
<span class="absolute bottom-0 inset-x-0 h-px bg-violet-600 dark:bg-violet-400"></span>
|
|
475
|
+
{/if}
|
|
476
|
+
</button>
|
|
477
|
+
<button
|
|
478
|
+
onclick={switchToSearch}
|
|
479
|
+
class="relative flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors {searchVisible ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
|
|
480
|
+
>
|
|
481
|
+
<Icon name="lucide:search" class="w-3.5 h-3.5" />
|
|
482
|
+
Search
|
|
483
|
+
{#if searchVisible}
|
|
484
|
+
<span class="absolute bottom-0 inset-x-0 h-px bg-violet-600 dark:bg-violet-400"></span>
|
|
485
|
+
{/if}
|
|
486
|
+
</button>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<!-- Search Bar -->
|
|
479
490
|
{#if searchVisible}
|
|
480
491
|
<div class="px-3 py-2 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
|
481
492
|
<!-- Search Input -->
|
|
@@ -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
|