@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.
- package/backend/lib/chat/stream-manager.ts +8 -0
- package/backend/lib/database/migrations/022_add_snapshot_changes_column.ts +35 -0
- package/backend/lib/database/migrations/index.ts +7 -0
- package/backend/lib/database/queries/snapshot-queries.ts +7 -4
- package/backend/lib/files/file-watcher.ts +34 -0
- package/backend/lib/project/status-manager.ts +6 -4
- package/backend/lib/snapshot/snapshot-service.ts +471 -316
- package/backend/lib/terminal/pty-session-manager.ts +1 -32
- package/backend/ws/chat/stream.ts +45 -2
- package/backend/ws/snapshot/restore.ts +77 -67
- package/backend/ws/system/operations.ts +95 -0
- package/frontend/App.svelte +24 -7
- package/frontend/lib/components/chat/ChatInterface.svelte +14 -14
- package/frontend/lib/components/chat/input/ChatInput.svelte +2 -2
- package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +1 -1
- package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +8 -3
- package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +12 -2
- package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +3 -8
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +222 -30
- package/frontend/lib/components/common/ConnectionBanner.svelte +55 -0
- package/frontend/lib/components/common/MonacoEditor.svelte +14 -0
- package/frontend/lib/components/common/UpdateBanner.svelte +88 -0
- package/frontend/lib/components/common/xterm/XTerm.svelte +9 -0
- package/frontend/lib/components/common/xterm/xterm-service.ts +9 -0
- package/frontend/lib/components/git/DiffViewer.svelte +16 -2
- package/frontend/lib/components/history/HistoryModal.svelte +8 -4
- package/frontend/lib/components/settings/SettingsModal.svelte +2 -2
- package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +59 -0
- package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
- package/frontend/lib/components/settings/general/UpdateSettings.svelte +123 -0
- package/frontend/lib/components/terminal/Terminal.svelte +1 -7
- package/frontend/lib/components/workspace/DesktopNavigator.svelte +11 -19
- package/frontend/lib/components/workspace/MobileNavigator.svelte +4 -15
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +1 -1
- package/frontend/lib/components/workspace/panels/FilesPanel.svelte +3 -2
- package/frontend/lib/components/workspace/panels/GitPanel.svelte +3 -2
- package/frontend/lib/services/notification/global-stream-monitor.ts +56 -16
- package/frontend/lib/services/snapshot/snapshot.service.ts +71 -32
- package/frontend/lib/stores/core/app.svelte.ts +47 -0
- package/frontend/lib/stores/core/presence.svelte.ts +80 -1
- 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 +10 -1
- package/frontend/lib/stores/features/terminal.svelte.ts +6 -0
- package/frontend/lib/stores/ui/connection.svelte.ts +40 -0
- package/frontend/lib/stores/ui/update.svelte.ts +124 -0
- package/frontend/lib/stores/ui/workspace.svelte.ts +4 -3
- package/frontend/lib/utils/ws.ts +5 -1
- package/index.html +1 -1
- package/package.json +1 -1
- package/shared/types/database/schema.ts +18 -0
- package/shared/types/stores/settings.ts +4 -0
- package/shared/utils/ws-client.ts +16 -2
- 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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",
|
|
@@ -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
|
}
|