@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.
Files changed (37) 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/frontend/lib/components/chat/ChatInterface.svelte +14 -14
  12. package/frontend/lib/components/chat/input/ChatInput.svelte +2 -2
  13. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +1 -1
  14. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +8 -3
  15. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +12 -2
  16. package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +3 -8
  17. package/frontend/lib/components/checkpoint/TimelineModal.svelte +222 -30
  18. package/frontend/lib/components/common/MonacoEditor.svelte +14 -0
  19. package/frontend/lib/components/common/xterm/XTerm.svelte +9 -0
  20. package/frontend/lib/components/common/xterm/xterm-service.ts +9 -0
  21. package/frontend/lib/components/git/DiffViewer.svelte +16 -2
  22. package/frontend/lib/components/history/HistoryModal.svelte +3 -4
  23. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +59 -0
  24. package/frontend/lib/components/terminal/Terminal.svelte +1 -7
  25. package/frontend/lib/components/workspace/DesktopNavigator.svelte +11 -19
  26. package/frontend/lib/components/workspace/MobileNavigator.svelte +4 -15
  27. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +3 -2
  28. package/frontend/lib/components/workspace/panels/GitPanel.svelte +3 -2
  29. package/frontend/lib/services/notification/global-stream-monitor.ts +56 -16
  30. package/frontend/lib/services/snapshot/snapshot.service.ts +71 -32
  31. package/frontend/lib/stores/core/presence.svelte.ts +63 -1
  32. package/frontend/lib/stores/features/settings.svelte.ts +9 -1
  33. package/frontend/lib/stores/features/terminal.svelte.ts +6 -0
  34. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -3
  35. package/package.json +1 -1
  36. package/shared/types/database/schema.ts +18 -0
  37. 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
- // Get status color from presence data (single source of truth from backend)
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 {getStatusColor(projectState.currentProject?.id ?? '')}"></span></div>
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 {getStatusColor(project.id ?? '')}"
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 = 800;
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-80 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700'
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 = 800;
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-80 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700 flex flex-col'
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 chat:stream-finished events from the backend and triggers
7
- * notifications for ALL projects — both active and non-active.
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() to send this event to all
10
- * users who have been associated with the project, even if they switched
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 the WS event.
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 listener');
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, timestamp } = data;
38
+ const { projectId, status, chatSessionId } = data;
39
+
40
+ debug.log('notification', 'GlobalStreamMonitor: Stream finished', { projectId, status });
37
41
 
38
- debug.log('notification', 'GlobalStreamMonitor: Stream finished', {
39
- projectId,
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
- * 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
@@ -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: 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.1.4",
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
  }