@myrialabs/clopen 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +7 -1
  2. package/backend/ws/system/operations.ts +95 -0
  3. package/bin/clopen.ts +89 -0
  4. package/bun.lock +5 -203
  5. package/frontend/App.svelte +24 -7
  6. package/frontend/lib/components/chat/ChatInterface.svelte +2 -2
  7. package/frontend/lib/components/checkpoint/TimelineModal.svelte +1 -1
  8. package/frontend/lib/components/common/ConnectionBanner.svelte +55 -0
  9. package/frontend/lib/components/common/UpdateBanner.svelte +88 -0
  10. package/frontend/lib/components/files/FileTree.svelte +34 -23
  11. package/frontend/lib/components/history/HistoryModal.svelte +5 -0
  12. package/frontend/lib/components/settings/SettingsModal.svelte +2 -2
  13. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  14. package/frontend/lib/components/settings/general/UpdateSettings.svelte +123 -0
  15. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +1 -1
  16. package/frontend/lib/stores/core/app.svelte.ts +47 -0
  17. package/frontend/lib/stores/core/presence.svelte.ts +30 -13
  18. package/frontend/lib/stores/core/projects.svelte.ts +10 -2
  19. package/frontend/lib/stores/core/sessions.svelte.ts +15 -2
  20. package/frontend/lib/stores/features/settings.svelte.ts +2 -1
  21. package/frontend/lib/stores/ui/connection.svelte.ts +40 -0
  22. package/frontend/lib/stores/ui/update.svelte.ts +124 -0
  23. package/frontend/lib/utils/ws.ts +5 -1
  24. package/index.html +1 -1
  25. package/package.json +2 -10
  26. package/shared/types/stores/settings.ts +2 -0
  27. package/shared/utils/ws-client.ts +16 -2
  28. package/vite.config.ts +1 -0
@@ -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}
@@ -222,19 +222,14 @@
222
222
  }
223
223
  }
224
224
 
225
- // Toggle search visibility (preserve inputs, auto-submit on re-open)
226
- function toggleSearch() {
227
- searchVisible = !searchVisible;
228
- if (searchVisible) {
229
- setTimeout(() => {
230
- searchInputRef?.focus();
231
- // Auto-submit if there's already a query
232
- if (searchQuery.trim()) {
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
- <!-- Search toggle button -->
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
- <!-- Search Bar (toggle) -->
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-[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