@myrialabs/clopen 0.1.4 → 0.1.5
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/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/MonacoEditor.svelte +14 -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 +3 -4
- package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +59 -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/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/presence.svelte.ts +63 -1
- package/frontend/lib/stores/features/settings.svelte.ts +9 -1
- package/frontend/lib/stores/features/terminal.svelte.ts +6 -0
- package/frontend/lib/stores/ui/workspace.svelte.ts +4 -3
- package/package.json +1 -1
- package/shared/types/database/schema.ts +18 -0
- package/shared/types/stores/settings.ts +2 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
type PanelId
|
|
9
9
|
} from '$frontend/lib/stores/ui/workspace.svelte';
|
|
10
10
|
import { projectState, removeProject } from '$frontend/lib/stores/core/projects.svelte';
|
|
11
|
-
import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
|
|
11
|
+
import { presenceState, getProjectStatusColor } from '$frontend/lib/stores/core/presence.svelte';
|
|
12
12
|
import { openSettingsModal } from '$frontend/lib/stores/ui/settings-modal.svelte';
|
|
13
13
|
import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
|
|
14
14
|
import type { IconName } from '$shared/types/ui/icons';
|
|
@@ -65,18 +65,7 @@
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
// Shows real-time status for ALL projects, not just the active one.
|
|
70
|
-
// Uses backend-computed isWaitingInput so background sessions are accurate
|
|
71
|
-
// even when the frontend hasn't received their chat events.
|
|
72
|
-
function getStatusColor(projectId: string): string {
|
|
73
|
-
const status = presenceState.statuses.get(projectId);
|
|
74
|
-
if (!status?.streams) return 'bg-slate-500/30';
|
|
75
|
-
const activeStreams = status.streams.filter((s: any) => s.status === 'active');
|
|
76
|
-
if (activeStreams.length === 0) return 'bg-slate-500/30';
|
|
77
|
-
const hasWaitingInput = activeStreams.some((s: any) => s.isWaitingInput);
|
|
78
|
-
return hasWaitingInput ? 'bg-amber-500' : 'bg-emerald-500';
|
|
79
|
-
}
|
|
68
|
+
// Status color for project indicator — uses shared helper from presence store
|
|
80
69
|
|
|
81
70
|
function openAddProject() {
|
|
82
71
|
showProjectMenu = false;
|
|
@@ -162,7 +151,7 @@
|
|
|
162
151
|
aria-expanded={showProjectMenu}
|
|
163
152
|
aria-haspopup="menu"
|
|
164
153
|
>
|
|
165
|
-
<div class="relative shrink-0"><Icon name="lucide:folder-open" class="w-4 h-4" /><span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-slate-100 dark:border-slate-800 {
|
|
154
|
+
<div class="relative shrink-0"><Icon name="lucide:folder-open" class="w-4 h-4" /><span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-slate-100 dark:border-slate-800 {getProjectStatusColor(projectState.currentProject?.id ?? '')}"></span></div>
|
|
166
155
|
<span class="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">
|
|
167
156
|
{projectState.currentProject?.name ?? 'No Project'}
|
|
168
157
|
</span>
|
|
@@ -315,7 +304,7 @@
|
|
|
315
304
|
>
|
|
316
305
|
<Icon name="lucide:folder" class="text-violet-600 dark:text-violet-400 w-4 h-4" />
|
|
317
306
|
<span
|
|
318
|
-
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-900 {
|
|
307
|
+
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-900 {getProjectStatusColor(project.id ?? '')}"
|
|
319
308
|
></span>
|
|
320
309
|
</div>
|
|
321
310
|
<div class="flex-1 min-w-0">
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import Dialog from '$frontend/lib/components/common/Dialog.svelte';
|
|
13
13
|
import type { FileNode } from '$shared/types/filesystem';
|
|
14
14
|
import { debug } from '$shared/utils/logger';
|
|
15
|
+
import { settings } from '$frontend/lib/stores/features/settings.svelte';
|
|
15
16
|
import { onMount, onDestroy } from 'svelte';
|
|
16
17
|
import ws from '$frontend/lib/utils/ws';
|
|
17
18
|
import { showConfirm } from '$frontend/lib/stores/ui/dialog.svelte';
|
|
@@ -138,7 +139,7 @@
|
|
|
138
139
|
// Container width detection for 2-column layout
|
|
139
140
|
let containerRef = $state<HTMLDivElement | null>(null);
|
|
140
141
|
let containerWidth = $state(0);
|
|
141
|
-
const TWO_COLUMN_THRESHOLD =
|
|
142
|
+
const TWO_COLUMN_THRESHOLD = $derived(Math.round(600 * (settings.fontSize / 13)));
|
|
142
143
|
|
|
143
144
|
// FileTree ref
|
|
144
145
|
let fileTreeRef = $state<any>(null);
|
|
@@ -1185,7 +1186,7 @@
|
|
|
1185
1186
|
<!-- Tree panel: always rendered, hidden via CSS in 1-column viewer mode -->
|
|
1186
1187
|
<div
|
|
1187
1188
|
class={isTwoColumnMode
|
|
1188
|
-
? 'w-
|
|
1189
|
+
? 'w-72 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700'
|
|
1189
1190
|
: (viewMode === 'tree' ? 'w-full h-full overflow-hidden' : 'hidden')}
|
|
1190
1191
|
>
|
|
1191
1192
|
<div class="h-full overflow-auto" bind:this={treeScrollContainer}>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { projectState } from '$frontend/lib/stores/core/projects.svelte';
|
|
6
6
|
import { showError, showInfo } from '$frontend/lib/stores/ui/notification.svelte';
|
|
7
7
|
import { debug } from '$shared/utils/logger';
|
|
8
|
+
import { settings } from '$frontend/lib/stores/features/settings.svelte';
|
|
8
9
|
import ws from '$frontend/lib/utils/ws';
|
|
9
10
|
import { getFileIcon } from '$frontend/lib/utils/file-icon-mappings';
|
|
10
11
|
import { getGitStatusLabel, getGitStatusColor } from '$frontend/lib/utils/git-status';
|
|
@@ -131,7 +132,7 @@
|
|
|
131
132
|
// Container width for responsive layout (same threshold as Files: 800)
|
|
132
133
|
let containerRef = $state<HTMLDivElement | null>(null);
|
|
133
134
|
let containerWidth = $state(0);
|
|
134
|
-
const TWO_COLUMN_THRESHOLD =
|
|
135
|
+
const TWO_COLUMN_THRESHOLD = $derived(Math.round(600 * (settings.fontSize / 13)));
|
|
135
136
|
const isTwoColumnMode = $derived(containerWidth >= TWO_COLUMN_THRESHOLD);
|
|
136
137
|
|
|
137
138
|
// Track last project for re-fetch
|
|
@@ -1504,7 +1505,7 @@
|
|
|
1504
1505
|
<!-- Left panel: Changes list (w-80 like Files panel tree) -->
|
|
1505
1506
|
<div
|
|
1506
1507
|
class={isTwoColumnMode
|
|
1507
|
-
? 'w-
|
|
1508
|
+
? 'w-72 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700 flex flex-col'
|
|
1508
1509
|
: (viewMode === 'list' ? 'w-full h-full overflow-hidden flex flex-col' : 'hidden')}
|
|
1509
1510
|
>
|
|
1510
1511
|
{@render changesList()}
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for chat stream notifications (sound + push).
|
|
5
5
|
*
|
|
6
|
-
* Listens for
|
|
7
|
-
*
|
|
6
|
+
* Listens for backend events and triggers notifications for ALL projects —
|
|
7
|
+
* both active and non-active:
|
|
8
|
+
* - chat:stream-finished → stream completed/errored/cancelled
|
|
9
|
+
* - chat:waiting-input → AskUserQuestion requires user input
|
|
8
10
|
*
|
|
9
|
-
* The backend uses ws.emit.projectMembers()
|
|
10
|
-
*
|
|
11
|
-
* to a different project.
|
|
11
|
+
* The backend uses ws.emit.projectMembers() so notifications reach users
|
|
12
|
+
* even when they are on a different project or session.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { soundNotification, pushNotification } from '$frontend/lib/services/notification';
|
|
@@ -19,27 +20,27 @@ import ws from '$frontend/lib/utils/ws';
|
|
|
19
20
|
class GlobalStreamMonitor {
|
|
20
21
|
private initialized = false;
|
|
21
22
|
|
|
23
|
+
/** Track notified AskUserQuestion tool_use IDs to ensure once-only notification */
|
|
24
|
+
private notifiedToolUseIds = new Set<string>();
|
|
25
|
+
|
|
22
26
|
/**
|
|
23
|
-
* Initialize the monitor - subscribes to
|
|
27
|
+
* Initialize the monitor - subscribes to WS events.
|
|
24
28
|
* Safe to call multiple times (idempotent).
|
|
25
|
-
*
|
|
26
|
-
* Triggers sound/push for ALL projects the user is a member of,
|
|
27
|
-
* not just the active one — background streams deserve notifications too.
|
|
28
29
|
*/
|
|
29
30
|
initialize(): void {
|
|
30
31
|
if (this.initialized) return;
|
|
31
32
|
this.initialized = true;
|
|
32
33
|
|
|
33
|
-
debug.log('notification', 'GlobalStreamMonitor: Initializing WS
|
|
34
|
+
debug.log('notification', 'GlobalStreamMonitor: Initializing WS listeners');
|
|
34
35
|
|
|
36
|
+
// Stream finished — notify on completion
|
|
35
37
|
ws.on('chat:stream-finished', async (data) => {
|
|
36
|
-
const { projectId, status,
|
|
38
|
+
const { projectId, status, chatSessionId } = data;
|
|
39
|
+
|
|
40
|
+
debug.log('notification', 'GlobalStreamMonitor: Stream finished', { projectId, status });
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
status,
|
|
41
|
-
timestamp
|
|
42
|
-
});
|
|
42
|
+
// Clean up notified IDs for this session (stream is done)
|
|
43
|
+
this.clearSessionNotifications(chatSessionId);
|
|
43
44
|
|
|
44
45
|
// Play sound notification
|
|
45
46
|
try {
|
|
@@ -63,12 +64,51 @@ class GlobalStreamMonitor {
|
|
|
63
64
|
debug.error('notification', 'Error sending push notification:', error);
|
|
64
65
|
}
|
|
65
66
|
});
|
|
67
|
+
|
|
68
|
+
// Waiting for input — notify once per AskUserQuestion
|
|
69
|
+
ws.on('chat:waiting-input', async (data) => {
|
|
70
|
+
const { projectId, chatSessionId, toolUseId } = data;
|
|
71
|
+
|
|
72
|
+
// Deduplicate: only notify once per tool_use ID
|
|
73
|
+
if (this.notifiedToolUseIds.has(toolUseId)) return;
|
|
74
|
+
this.notifiedToolUseIds.add(toolUseId);
|
|
75
|
+
|
|
76
|
+
debug.log('notification', 'GlobalStreamMonitor: Waiting for input', { projectId, chatSessionId, toolUseId });
|
|
77
|
+
|
|
78
|
+
// Play sound notification
|
|
79
|
+
try {
|
|
80
|
+
await soundNotification.play();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
debug.error('notification', 'Error playing sound notification:', error);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Send push notification
|
|
86
|
+
try {
|
|
87
|
+
const projectName = projectState.projects.find(p => p.id === projectId)?.name || 'Unknown';
|
|
88
|
+
await pushNotification.sendChatComplete(`Waiting for your input in "${projectName}"`);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
debug.error('notification', 'Error sending push notification:', error);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove tracked tool_use IDs for a finished session
|
|
97
|
+
*/
|
|
98
|
+
private clearSessionNotifications(chatSessionId: string): void {
|
|
99
|
+
// Tool IDs are globally unique, so a simple clear-on-stream-finish
|
|
100
|
+
// is enough — they won't collide across sessions.
|
|
101
|
+
// For long-running apps, periodically trim to avoid unbounded growth.
|
|
102
|
+
if (this.notifiedToolUseIds.size > 500) {
|
|
103
|
+
this.notifiedToolUseIds.clear();
|
|
104
|
+
}
|
|
66
105
|
}
|
|
67
106
|
|
|
68
107
|
/**
|
|
69
108
|
* Clear state (for cleanup/testing)
|
|
70
109
|
*/
|
|
71
110
|
clear(): void {
|
|
111
|
+
this.notifiedToolUseIds.clear();
|
|
72
112
|
debug.log('notification', 'GlobalStreamMonitor: Clearing state');
|
|
73
113
|
}
|
|
74
114
|
}
|
|
@@ -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
|
|
@@ -2,11 +2,14 @@
|
|
|
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 } from '$frontend/lib/stores/core/app.svelte';
|
|
10
13
|
|
|
11
14
|
// Shared reactive state
|
|
12
15
|
export const presenceState = $state<{
|
|
@@ -46,3 +49,62 @@ export function initPresence() {
|
|
|
46
49
|
export function getProjectPresence(projectId: string): ProjectStatus | undefined {
|
|
47
50
|
return presenceState.statuses.get(projectId);
|
|
48
51
|
}
|
|
52
|
+
|
|
53
|
+
// ========================================
|
|
54
|
+
// UNIFIED STATUS HELPERS
|
|
55
|
+
// ========================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if a chat session is waiting for user input.
|
|
59
|
+
* Merges two sources so the result is always up-to-date:
|
|
60
|
+
* 1. Frontend appState.sessionStates (instant, set by chat stream events)
|
|
61
|
+
* 2. Backend presenceState (works for background / other-project sessions)
|
|
62
|
+
*/
|
|
63
|
+
export function isSessionWaitingInput(chatSessionId: string, projectId?: string): boolean {
|
|
64
|
+
// Frontend app state — immediate for the active session
|
|
65
|
+
if (appState.sessionStates[chatSessionId]?.isWaitingInput) return true;
|
|
66
|
+
|
|
67
|
+
// Backend presence — covers background & cross-project sessions
|
|
68
|
+
if (projectId) {
|
|
69
|
+
const status = presenceState.statuses.get(projectId);
|
|
70
|
+
if (status?.streams?.some(
|
|
71
|
+
(s: any) => s.status === 'active' && s.chatSessionId === chatSessionId && s.isWaitingInput
|
|
72
|
+
)) return true;
|
|
73
|
+
} else {
|
|
74
|
+
// No projectId provided — search all projects
|
|
75
|
+
for (const status of presenceState.statuses.values()) {
|
|
76
|
+
if (status.streams?.some(
|
|
77
|
+
(s: any) => s.status === 'active' && s.chatSessionId === chatSessionId && s.isWaitingInput
|
|
78
|
+
)) return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get the status indicator color for a project.
|
|
87
|
+
* - Gray (bg-slate-500/30) : no active streams (idle)
|
|
88
|
+
* Priority (highest wins when multiple sessions exist):
|
|
89
|
+
* - Green (bg-emerald-500) : at least one stream actively processing
|
|
90
|
+
* - Amber (bg-amber-500) : all active streams waiting for user input
|
|
91
|
+
* - Gray (bg-slate-500/30) : no active streams (idle)
|
|
92
|
+
*
|
|
93
|
+
* Merges backend presence with frontend app state for accuracy.
|
|
94
|
+
*/
|
|
95
|
+
export function getProjectStatusColor(projectId: string): string {
|
|
96
|
+
const status = presenceState.statuses.get(projectId);
|
|
97
|
+
if (!status?.streams) return 'bg-slate-500/30';
|
|
98
|
+
|
|
99
|
+
const activeStreams = status.streams.filter((s: any) => s.status === 'active');
|
|
100
|
+
if (activeStreams.length === 0) return 'bg-slate-500/30';
|
|
101
|
+
|
|
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';
|
|
107
|
+
|
|
108
|
+
// All active streams are waiting for input
|
|
109
|
+
return 'bg-amber-500';
|
|
110
|
+
}
|
|
@@ -32,12 +32,19 @@ const defaultSettings: AppSettings = {
|
|
|
32
32
|
soundNotifications: true,
|
|
33
33
|
pushNotifications: false,
|
|
34
34
|
layoutPresetVisibility: createDefaultPresetVisibility(),
|
|
35
|
-
allowedBasePaths: []
|
|
35
|
+
allowedBasePaths: [],
|
|
36
|
+
fontSize: 13
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
// Create and export reactive settings state directly (starts with defaults)
|
|
39
40
|
export const settings = $state<AppSettings>({ ...defaultSettings });
|
|
40
41
|
|
|
42
|
+
export function applyFontSize(size: number): void {
|
|
43
|
+
if (typeof window !== 'undefined') {
|
|
44
|
+
document.documentElement.style.fontSize = `${size}px`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
/**
|
|
42
49
|
* Apply server-provided settings during initialization.
|
|
43
50
|
* Called from WorkspaceLayout with state from user:restore-state.
|
|
@@ -46,6 +53,7 @@ export function applyServerSettings(serverSettings: Partial<AppSettings> | null)
|
|
|
46
53
|
if (serverSettings && typeof serverSettings === 'object') {
|
|
47
54
|
// Merge with defaults to ensure all properties exist
|
|
48
55
|
Object.assign(settings, { ...defaultSettings, ...serverSettings });
|
|
56
|
+
applyFontSize(settings.fontSize);
|
|
49
57
|
debug.log('settings', 'Applied server settings');
|
|
50
58
|
}
|
|
51
59
|
}
|
|
@@ -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
|
}
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myrialabs/clopen",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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,6 @@ 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;
|
|
15
17
|
}
|