@myrialabs/clopen 0.1.4 → 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.
Files changed (54) hide show
  1. package/backend/lib/chat/stream-manager.ts +8 -0
  2. package/backend/lib/database/migrations/022_add_snapshot_changes_column.ts +35 -0
  3. package/backend/lib/database/migrations/index.ts +7 -0
  4. package/backend/lib/database/queries/snapshot-queries.ts +7 -4
  5. package/backend/lib/files/file-watcher.ts +34 -0
  6. package/backend/lib/project/status-manager.ts +6 -4
  7. package/backend/lib/snapshot/snapshot-service.ts +471 -316
  8. package/backend/lib/terminal/pty-session-manager.ts +1 -32
  9. package/backend/ws/chat/stream.ts +45 -2
  10. package/backend/ws/snapshot/restore.ts +77 -67
  11. package/backend/ws/system/operations.ts +95 -0
  12. package/frontend/App.svelte +24 -7
  13. package/frontend/lib/components/chat/ChatInterface.svelte +14 -14
  14. package/frontend/lib/components/chat/input/ChatInput.svelte +2 -2
  15. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +1 -1
  16. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +8 -3
  17. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +12 -2
  18. package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +3 -8
  19. package/frontend/lib/components/checkpoint/TimelineModal.svelte +222 -30
  20. package/frontend/lib/components/common/ConnectionBanner.svelte +55 -0
  21. package/frontend/lib/components/common/MonacoEditor.svelte +14 -0
  22. package/frontend/lib/components/common/UpdateBanner.svelte +88 -0
  23. package/frontend/lib/components/common/xterm/XTerm.svelte +9 -0
  24. package/frontend/lib/components/common/xterm/xterm-service.ts +9 -0
  25. package/frontend/lib/components/git/DiffViewer.svelte +16 -2
  26. package/frontend/lib/components/history/HistoryModal.svelte +8 -4
  27. package/frontend/lib/components/settings/SettingsModal.svelte +2 -2
  28. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +59 -0
  29. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  30. package/frontend/lib/components/settings/general/UpdateSettings.svelte +123 -0
  31. package/frontend/lib/components/terminal/Terminal.svelte +1 -7
  32. package/frontend/lib/components/workspace/DesktopNavigator.svelte +11 -19
  33. package/frontend/lib/components/workspace/MobileNavigator.svelte +4 -15
  34. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +1 -1
  35. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +3 -2
  36. package/frontend/lib/components/workspace/panels/GitPanel.svelte +3 -2
  37. package/frontend/lib/services/notification/global-stream-monitor.ts +56 -16
  38. package/frontend/lib/services/snapshot/snapshot.service.ts +71 -32
  39. package/frontend/lib/stores/core/app.svelte.ts +47 -0
  40. package/frontend/lib/stores/core/presence.svelte.ts +80 -1
  41. package/frontend/lib/stores/core/projects.svelte.ts +10 -2
  42. package/frontend/lib/stores/core/sessions.svelte.ts +15 -2
  43. package/frontend/lib/stores/features/settings.svelte.ts +10 -1
  44. package/frontend/lib/stores/features/terminal.svelte.ts +6 -0
  45. package/frontend/lib/stores/ui/connection.svelte.ts +40 -0
  46. package/frontend/lib/stores/ui/update.svelte.ts +124 -0
  47. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -3
  48. package/frontend/lib/utils/ws.ts +5 -1
  49. package/index.html +1 -1
  50. package/package.json +1 -1
  51. package/shared/types/database/schema.ts +18 -0
  52. package/shared/types/stores/settings.ts +4 -0
  53. package/shared/utils/ws-client.ts +16 -2
  54. package/vite.config.ts +1 -0
@@ -1,43 +1,82 @@
1
1
  /**
2
- * Snapshot Service - WebSocket Implementation
2
+ * Snapshot Service - WebSocket Implementation (v2 - Session-Scoped)
3
3
  *
4
- * Provides snapshot/restore functionality using WebSocket
4
+ * Provides snapshot/restore functionality using WebSocket.
5
+ * v2 adds conflict detection and session-scoped restore.
5
6
  */
6
7
 
7
8
  import ws from '$frontend/lib/utils/ws';
8
9
  import type { TimelineResponse } from '$frontend/lib/components/checkpoint/timeline/types';
9
10
 
11
+ /**
12
+ * Conflict information for a single file
13
+ */
14
+ export interface RestoreConflict {
15
+ filepath: string;
16
+ modifiedBySessionId: string;
17
+ modifiedBySnapshotId: string;
18
+ modifiedAt: string;
19
+ restoreContent?: string;
20
+ currentContent?: string;
21
+ }
22
+
23
+ /**
24
+ * Result of conflict detection
25
+ */
26
+ export interface RestoreConflictCheck {
27
+ hasConflicts: boolean;
28
+ conflicts: RestoreConflict[];
29
+ checkpointsToUndo: string[];
30
+ }
31
+
32
+ /**
33
+ * User's resolution for conflicting files
34
+ */
35
+ export type ConflictResolution = Record<string, 'restore' | 'keep'>;
36
+
10
37
  class SnapshotService {
11
- /**
12
- * Get timeline data for a session
13
- */
14
- async getTimeline(sessionId: string): Promise<TimelineResponse> {
15
- return ws.http('snapshot:get-timeline', { sessionId });
16
- }
17
-
18
- /**
19
- * Restore to a checkpoint (unified - replaces undo/redo)
20
- */
21
- async restore(messageId: string, sessionId: string): Promise<any> {
22
- return ws.http('snapshot:restore', { messageId, sessionId });
23
- }
24
-
25
- /**
26
- * @deprecated Use restore() instead. Kept for backward compatibility.
27
- */
28
- async undo(messageId: string, sessionId: string): Promise<any> {
29
- return this.restore(messageId, sessionId);
30
- }
31
-
32
- /**
33
- * @deprecated Use restore() instead. Kept for backward compatibility.
34
- */
35
- async redo(branchName: string, sessionId: string, messageId?: string): Promise<any> {
36
- if (messageId) {
37
- return this.restore(messageId, sessionId);
38
- }
39
- throw new Error('messageId is required for restore operation');
40
- }
38
+ /**
39
+ * Get timeline data for a session
40
+ */
41
+ async getTimeline(sessionId: string): Promise<TimelineResponse> {
42
+ return ws.http('snapshot:get-timeline', { sessionId });
43
+ }
44
+
45
+ /**
46
+ * Check for conflicts before restoring to a checkpoint.
47
+ * Should be called before restore() to detect cross-session conflicts.
48
+ */
49
+ async checkConflicts(messageId: string, sessionId: string): Promise<RestoreConflictCheck> {
50
+ return ws.http('snapshot:check-conflicts', { messageId, sessionId });
51
+ }
52
+
53
+ /**
54
+ * Restore to a checkpoint with optional conflict resolutions.
55
+ */
56
+ async restore(
57
+ messageId: string,
58
+ sessionId: string,
59
+ conflictResolutions?: ConflictResolution
60
+ ): Promise<any> {
61
+ return ws.http('snapshot:restore', { messageId, sessionId, conflictResolutions });
62
+ }
63
+
64
+ /**
65
+ * @deprecated Use restore() instead. Kept for backward compatibility.
66
+ */
67
+ async undo(messageId: string, sessionId: string): Promise<any> {
68
+ return this.restore(messageId, sessionId);
69
+ }
70
+
71
+ /**
72
+ * @deprecated Use restore() instead. Kept for backward compatibility.
73
+ */
74
+ async redo(branchName: string, sessionId: string, messageId?: string): Promise<any> {
75
+ if (messageId) {
76
+ return this.restore(messageId, sessionId);
77
+ }
78
+ throw new Error('messageId is required for restore operation');
79
+ }
41
80
  }
42
81
 
43
82
  // Export singleton instance
@@ -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
  // ========================================
@@ -2,11 +2,15 @@
2
2
  * Presence Store
3
3
  * Shared reactive state for project presence (active users)
4
4
  * Subscribes once to projectStatusService, shared across all components
5
+ *
6
+ * Also provides unified helpers for status indicators so all components
7
+ * read from one place (merges backend presence + frontend app state).
5
8
  */
6
9
 
7
- import { onMount } from 'svelte';
8
10
  import { projectStatusService, type ProjectStatus } from '$frontend/lib/services/project/status.service';
9
11
  import { userStore } from '$frontend/lib/stores/features/user.svelte';
12
+ import { appState, markSessionUnread, hasUnreadSessionsForProject } from '$frontend/lib/stores/core/app.svelte';
13
+ import { sessionState } from '$frontend/lib/stores/core/sessions.svelte';
10
14
 
11
15
  // Shared reactive state
12
16
  export const presenceState = $state<{
@@ -27,6 +31,7 @@ export function initPresence() {
27
31
 
28
32
  projectStatusService.onStatusUpdate((statuses) => {
29
33
  const currentUserId = userStore.currentUser?.id;
34
+ const currentSessionId = sessionState.currentSession?.id;
30
35
  const statusMap = new Map<string, ProjectStatus>();
31
36
  statuses.forEach((status) => {
32
37
  statusMap.set(status.projectId, {
@@ -35,6 +40,15 @@ export function initPresence() {
35
40
  ? status.activeUsers.filter((u) => u.userId !== currentUserId)
36
41
  : status.activeUsers
37
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
+ }
38
52
  });
39
53
  presenceState.statuses = statusMap;
40
54
  });
@@ -46,3 +60,68 @@ export function initPresence() {
46
60
  export function getProjectPresence(projectId: string): ProjectStatus | undefined {
47
61
  return presenceState.statuses.get(projectId);
48
62
  }
63
+
64
+ // ========================================
65
+ // UNIFIED STATUS HELPERS
66
+ // ========================================
67
+
68
+ /**
69
+ * Check if a chat session is waiting for user input.
70
+ * Merges two sources so the result is always up-to-date:
71
+ * 1. Frontend appState.sessionStates (instant, set by chat stream events)
72
+ * 2. Backend presenceState (works for background / other-project sessions)
73
+ */
74
+ export function isSessionWaitingInput(chatSessionId: string, projectId?: string): boolean {
75
+ // Frontend app state — immediate for the active session
76
+ if (appState.sessionStates[chatSessionId]?.isWaitingInput) return true;
77
+
78
+ // Backend presence — covers background & cross-project sessions
79
+ if (projectId) {
80
+ const status = presenceState.statuses.get(projectId);
81
+ if (status?.streams?.some(
82
+ (s: any) => s.status === 'active' && s.chatSessionId === chatSessionId && s.isWaitingInput
83
+ )) return true;
84
+ } else {
85
+ // No projectId provided — search all projects
86
+ for (const status of presenceState.statuses.values()) {
87
+ if (status.streams?.some(
88
+ (s: any) => s.status === 'active' && s.chatSessionId === chatSessionId && s.isWaitingInput
89
+ )) return true;
90
+ }
91
+ }
92
+
93
+ return false;
94
+ }
95
+
96
+ /**
97
+ * Get the status indicator color for a project.
98
+ * Priority (highest wins when multiple sessions exist):
99
+ * - Green (bg-emerald-500) : at least one stream actively processing
100
+ * - Amber (bg-amber-500) : all active streams waiting for user input
101
+ * - Blue (bg-blue-500) : session(s) with unread activity
102
+ * - Gray (bg-slate-500/30) : idle
103
+ *
104
+ * Merges backend presence with frontend app state for accuracy.
105
+ */
106
+ export function getProjectStatusColor(projectId: string): string {
107
+ const status = presenceState.statuses.get(projectId);
108
+
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';
117
+
118
+ // All active streams are waiting for input
119
+ return 'bg-amber-500';
120
+ }
121
+
122
+ // Check for unread sessions in this project
123
+ if (hasUnreadSessionsForProject(projectId)) return 'bg-blue-500';
124
+
125
+ return 'bg-slate-500/30';
126
+ }
127
+
@@ -109,14 +109,22 @@ export async function setCurrentProject(project: Project | null) {
109
109
 
110
110
  // Reload all sessions for this project from server
111
111
  // (local state may only have sessions from the previous project)
112
- await reloadSessionsForProject();
112
+ const savedSessionId = await reloadSessionsForProject();
113
113
 
114
114
  // Check if there's an existing session for this project
115
115
  const existingSessions = getSessionsForProject(project.id);
116
116
  const activeSessions = existingSessions
117
117
  .filter(s => !s.ended_at)
118
118
  .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
119
- const activeSession = activeSessions[0] || null;
119
+
120
+ // Try server-saved session first (preserves user's last selected session)
121
+ let activeSession = savedSessionId
122
+ ? activeSessions.find(s => s.id === savedSessionId) || null
123
+ : null;
124
+ // Fall back to most recent active session
125
+ if (!activeSession) {
126
+ activeSession = activeSessions[0] || null;
127
+ }
120
128
 
121
129
  if (activeSession) {
122
130
  // Restore the most recent active session for this project
@@ -13,6 +13,7 @@ import { buildMetadataFromTransport } from '$shared/utils/message-formatter';
13
13
  import ws from '$frontend/lib/utils/ws';
14
14
  import { projectState } from './projects.svelte';
15
15
  import { setupEditModeListener, restoreEditMode } from '$frontend/lib/stores/ui/edit-mode.svelte';
16
+ import { markSessionUnread, markSessionRead } from '$frontend/lib/stores/core/app.svelte';
16
17
  import { debug } from '$shared/utils/logger';
17
18
 
18
19
  interface SessionState {
@@ -56,6 +57,11 @@ export async function setCurrentSession(session: ChatSession | null, skipLoadMes
56
57
  const previousSessionId = sessionState.currentSession?.id;
57
58
  sessionState.currentSession = session;
58
59
 
60
+ // Clear unread status when viewing a session
61
+ if (session) {
62
+ markSessionRead(session.id);
63
+ }
64
+
59
65
  // Leave previous chat session room
60
66
  if (previousSessionId && previousSessionId !== session?.id) {
61
67
  ws.emit('chat:leave-session', { chatSessionId: previousSessionId });
@@ -300,11 +306,11 @@ export function getRecentSessions(limit: number = 10): ChatSession[] {
300
306
  * Reload sessions for the current project from the server.
301
307
  * Called when the user switches projects so session list stays in sync.
302
308
  */
303
- export async function reloadSessionsForProject() {
309
+ export async function reloadSessionsForProject(): Promise<string | null> {
304
310
  try {
305
311
  const response = await ws.http('sessions:list');
306
312
  if (response) {
307
- const { sessions } = response;
313
+ const { sessions, currentSessionId } = response;
308
314
  // Merge: keep sessions from other projects, replace sessions for current project
309
315
  const currentProjectId = projectState.currentProject?.id;
310
316
  if (currentProjectId) {
@@ -315,10 +321,12 @@ export async function reloadSessionsForProject() {
315
321
  } else {
316
322
  sessionState.sessions = sessions;
317
323
  }
324
+ return currentSessionId || null;
318
325
  }
319
326
  } catch (error) {
320
327
  debug.error('session', 'Error reloading sessions:', error);
321
328
  }
329
+ return null;
322
330
  }
323
331
 
324
332
  // ========================================
@@ -345,6 +353,11 @@ function setupCollaborativeListeners() {
345
353
  } else {
346
354
  sessionState.sessions[existingIndex] = session;
347
355
  }
356
+
357
+ // Mark as unread if it's not the current session
358
+ if (session.id !== sessionState.currentSession?.id) {
359
+ markSessionUnread(session.id, session.project_id);
360
+ }
348
361
  });
349
362
 
350
363
  // Listen for session deletion broadcasts from other users
@@ -32,12 +32,20 @@ const defaultSettings: AppSettings = {
32
32
  soundNotifications: true,
33
33
  pushNotifications: false,
34
34
  layoutPresetVisibility: createDefaultPresetVisibility(),
35
- allowedBasePaths: []
35
+ allowedBasePaths: [],
36
+ fontSize: 13,
37
+ autoUpdate: false
36
38
  };
37
39
 
38
40
  // Create and export reactive settings state directly (starts with defaults)
39
41
  export const settings = $state<AppSettings>({ ...defaultSettings });
40
42
 
43
+ export function applyFontSize(size: number): void {
44
+ if (typeof window !== 'undefined') {
45
+ document.documentElement.style.fontSize = `${size}px`;
46
+ }
47
+ }
48
+
41
49
  /**
42
50
  * Apply server-provided settings during initialization.
43
51
  * Called from WorkspaceLayout with state from user:restore-state.
@@ -46,6 +54,7 @@ export function applyServerSettings(serverSettings: Partial<AppSettings> | null)
46
54
  if (serverSettings && typeof serverSettings === 'object') {
47
55
  // Merge with defaults to ensure all properties exist
48
56
  Object.assign(settings, { ...defaultSettings, ...serverSettings });
57
+ applyFontSize(settings.fontSize);
49
58
  debug.log('settings', 'Applied server settings');
50
59
  }
51
60
  }
@@ -469,6 +469,12 @@ export const terminalStore = {
469
469
 
470
470
  case 'error':
471
471
  if (data.content) {
472
+ // If PTY session is gone, auto-close the tab instead of showing error
473
+ if (data.content.includes('Session not found') || data.content.includes('PTY not available')) {
474
+ debug.log('terminal', `🧹 Auto-closing dead terminal tab: ${sessionId}`);
475
+ this.closeSession(sessionId);
476
+ return;
477
+ }
472
478
  // Error messages are usually complete, but still buffer for safety
473
479
  this.processBufferedOutput(sessionId, data.content, 'error');
474
480
  }
@@ -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
+ }
@@ -513,7 +513,7 @@ export const workspaceState = $state<WorkspaceState>({
513
513
  layout: defaultPreset.layout,
514
514
  activePresetId: 'main-stack',
515
515
  navigatorCollapsed: false,
516
- navigatorWidth: 220,
516
+ navigatorWidth: 200,
517
517
  activeMobilePanel: 'chat'
518
518
  });
519
519
 
@@ -732,8 +732,9 @@ export function toggleNavigator(): void {
732
732
  saveWorkspaceState();
733
733
  }
734
734
 
735
- export function setNavigatorWidth(width: number): void {
736
- workspaceState.navigatorWidth = Math.max(200, Math.min(400, width));
735
+ export function setNavigatorWidth(width: number, fontSize: number = 13): void {
736
+ const scale = fontSize / 13;
737
+ workspaceState.navigatorWidth = Math.max(Math.round(180 * scale), Math.min(Math.round(400 * scale), width));
737
738
  saveWorkspaceState();
738
739
  }
739
740
 
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { WSClient } from '$shared/utils/ws-client';
8
8
  import type { WSAPI } from '$backend/ws';
9
+ import { setConnectionStatus } from '$frontend/lib/stores/ui/connection.svelte';
9
10
 
10
11
  /**
11
12
  * Get WebSocket URL based on environment
@@ -21,7 +22,10 @@ const ws = new WSClient<WSAPI>(getWebSocketUrl(), {
21
22
  autoReconnect: true,
22
23
  maxReconnectAttempts: 0, // Infinite reconnect
23
24
  reconnectDelay: 1000,
24
- maxReconnectDelay: 30000
25
+ maxReconnectDelay: 30000,
26
+ onStatusChange: (status, reconnectAttempts) => {
27
+ setConnectionStatus(status, reconnectAttempts);
28
+ }
25
29
  });
26
30
 
27
31
  // CRITICAL: Handle Vite HMR to prevent WebSocket connection accumulation
package/index.html CHANGED
@@ -59,7 +59,7 @@
59
59
  </script>
60
60
  </head>
61
61
  <body
62
- class="min-h-screen bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100 transition-colors duration-200"
62
+ class="min-h-dvh bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100 transition-colors duration-200"
63
63
  >
64
64
  <!-- App mount point -->
65
65
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.1.4",
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",
@@ -107,6 +107,24 @@ export interface MessageSnapshot {
107
107
  branch_id?: string | null; // Branch identifier for multi-branch redo
108
108
  // Blob store format (new): tree hash for content-addressable storage
109
109
  tree_hash?: string | null; // When set, snapshot uses blob store (files in ~/.clopen/snapshots/)
110
+ // Session-scoped changes: { filepath: { oldHash, newHash } }
111
+ session_changes?: string | null; // JSON string of SessionScopedChanges
112
+ }
113
+
114
+ /**
115
+ * Session-scoped file change entry for a single file
116
+ */
117
+ export interface SessionFileChange {
118
+ oldHash: string; // Hash of file content before change
119
+ newHash: string; // Hash of file content after change
120
+ }
121
+
122
+ /**
123
+ * Session-scoped changes map: filepath → { oldHash, newHash }
124
+ * Used for session-scoped restore and conflict detection
125
+ */
126
+ export interface SessionScopedChanges {
127
+ [filepath: string]: SessionFileChange;
110
128
  }
111
129
 
112
130
  /**
@@ -12,4 +12,8 @@ export interface AppSettings {
12
12
  layoutPresetVisibility: Record<string, boolean>;
13
13
  /** Restrict folder browser to only these base paths. Empty = no restriction. */
14
14
  allowedBasePaths: string[];
15
+ /** Base font size in pixels (10–20). Default: 13. */
16
+ fontSize: number;
17
+ /** Automatically update to the latest version when available. Default: false. */
18
+ autoUpdate: boolean;
15
19
  }