@myrialabs/clopen 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/backend/lib/database/migrations/023_create_user_unread_sessions_table.ts +32 -0
  2. package/backend/lib/database/migrations/index.ts +7 -0
  3. package/backend/lib/database/queries/session-queries.ts +37 -0
  4. package/backend/lib/git/git-service.ts +1 -0
  5. package/backend/ws/sessions/crud.ts +34 -2
  6. package/backend/ws/user/crud.ts +8 -4
  7. package/bun.lock +34 -12
  8. package/frontend/lib/components/common/MonacoEditor.svelte +6 -6
  9. package/frontend/lib/components/common/xterm/XTerm.svelte +27 -108
  10. package/frontend/lib/components/common/xterm/terminal-config.ts +2 -2
  11. package/frontend/lib/components/common/xterm/types.ts +1 -0
  12. package/frontend/lib/components/common/xterm/xterm-service.ts +69 -20
  13. package/frontend/lib/components/files/FileTree.svelte +4 -6
  14. package/frontend/lib/components/files/FileViewer.svelte +45 -101
  15. package/frontend/lib/components/git/CommitForm.svelte +1 -1
  16. package/frontend/lib/components/git/GitLog.svelte +141 -101
  17. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +81 -72
  18. package/frontend/lib/components/settings/SettingsModal.svelte +1 -8
  19. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +3 -3
  20. package/frontend/lib/components/terminal/Terminal.svelte +1 -1
  21. package/frontend/lib/components/terminal/TerminalTabs.svelte +28 -26
  22. package/frontend/lib/components/workspace/PanelHeader.svelte +639 -623
  23. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +3 -2
  24. package/frontend/lib/components/workspace/panels/GitPanel.svelte +34 -92
  25. package/frontend/lib/stores/core/app.svelte.ts +46 -0
  26. package/frontend/lib/stores/core/sessions.svelte.ts +24 -3
  27. package/frontend/lib/stores/ui/workspace.svelte.ts +14 -14
  28. package/package.json +8 -6
@@ -8,7 +8,7 @@
8
8
  workspaceState,
9
9
  initializeWorkspace,
10
10
  } from '$frontend/lib/stores/ui/workspace.svelte';
11
- import { appState, setAppLoading, setAppInitialized, restoreLastView } from '$frontend/lib/stores/core/app.svelte';
11
+ import { appState, setAppLoading, setAppInitialized, restoreLastView, restoreUnreadSessions } from '$frontend/lib/stores/core/app.svelte';
12
12
  import { projectState } from '$frontend/lib/stores/core/projects.svelte';
13
13
  import { sessionState } from '$frontend/lib/stores/core/sessions.svelte';
14
14
 
@@ -89,7 +89,7 @@
89
89
 
90
90
  // Step 3: Restore user state from server
91
91
  setProgress(30, 'Restoring state...');
92
- let serverState: { currentProjectId: string | null; lastView: string | null; settings: any } | null = null;
92
+ let serverState: { currentProjectId: string | null; lastView: string | null; settings: any; unreadSessions: any } | null = null;
93
93
  try {
94
94
  serverState = await ws.http('user:restore-state', {});
95
95
  debug.log('workspace', 'Server state restored:', serverState);
@@ -103,6 +103,7 @@
103
103
  applyServerSettings(serverState.settings);
104
104
  }
105
105
  restoreLastView(serverState?.lastView);
106
+ restoreUnreadSessions(serverState?.unreadSessions);
106
107
  initPresence();
107
108
 
108
109
  // Step 5: Load projects (with server-restored currentProjectId)
@@ -67,9 +67,6 @@
67
67
  let newTagName = $state('');
68
68
  let newTagMessage = $state('');
69
69
 
70
- // More menu state (for Stash/Tags)
71
- let showMoreMenu = $state(false);
72
-
73
70
  // Tab system (like Files panel)
74
71
  interface DiffTab {
75
72
  id: string;
@@ -1053,12 +1050,21 @@
1053
1050
  gitStatus.staged.length + allChanges.length + gitStatus.conflicted.length
1054
1051
  );
1055
1052
 
1053
+ // View tabs config for tab bar
1054
+ const viewTabs = $derived([
1055
+ { id: 'changes' as const, label: 'Changes', icon: 'lucide:file-pen' as IconName, badge: totalChanges > 0 ? totalChanges : null },
1056
+ { id: 'log' as const, label: 'History', icon: 'lucide:history' as IconName, badge: null },
1057
+ { id: 'stash' as const, label: 'Stash', icon: 'lucide:archive' as IconName, badge: stashEntries.length > 0 ? stashEntries.length : null },
1058
+ { id: 'tags' as const, label: 'Tags', icon: 'lucide:tag' as IconName, badge: tags.length > 0 ? tags.length : null }
1059
+ ]);
1060
+
1056
1061
  // Exported panel actions for PanelHeader
1057
1062
  export const panelActions = {
1058
1063
  push: handlePush,
1059
1064
  pull: handlePull,
1060
1065
  fetch: handleFetch,
1061
1066
  init: handleInit,
1067
+ openBranchManager: () => { showBranchManager = true; },
1062
1068
  getBranchInfo: () => branchInfo,
1063
1069
  getIsRepo: () => isRepo,
1064
1070
  getIsFetching: () => isFetching,
@@ -1102,97 +1108,32 @@
1102
1108
  {/if}
1103
1109
  {/snippet}
1104
1110
 
1105
- <!-- Changes list snippet -->
1106
- {#snippet changesList()}
1107
- <!-- View tabs with branch switch -->
1108
- <div class="flex items-center gap-1 px-2 py-1.5 border-b border-slate-100 dark:border-slate-800">
1109
- <button
1110
- type="button"
1111
- class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md bg-slate-100 dark:bg-slate-800/60 text-slate-700 dark:text-slate-300 hover:bg-violet-500/10 hover:text-violet-600 transition-colors cursor-pointer border-none min-w-0"
1112
- onclick={() => showBranchManager = true}
1113
- title="Switch Branch"
1114
- >
1115
- <Icon name="lucide:git-branch" class="w-3.5 h-3.5 shrink-0" />
1116
- <span class="truncate max-w-24">{branchInfo?.current || '...'}</span>
1117
- </button>
1118
-
1119
- <div class="flex-1"></div>
1120
-
1121
- <!-- Primary tabs: Changes & History -->
1122
- <button
1123
- type="button"
1124
- class="px-2 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer border-none
1125
- {activeView === 'changes'
1126
- ? 'bg-violet-500/10 text-violet-600'
1127
- : 'bg-transparent text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'}"
1128
- onclick={() => { switchToView('changes'); showMoreMenu = false; }}
1129
- >
1130
- Changes{totalChanges > 0 ? ` (${totalChanges})` : ''}
1131
- </button>
1132
- <button
1133
- type="button"
1134
- class="px-2 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer border-none
1135
- {activeView === 'log'
1136
- ? 'bg-violet-500/10 text-violet-600'
1137
- : 'bg-transparent text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'}"
1138
- onclick={() => { switchToView('log'); showMoreMenu = false; }}
1139
- >
1140
- History
1141
- </button>
1142
-
1143
- <!-- More menu (Stash, Tags) -->
1144
- <div class="relative">
1111
+ <!-- View tabs snippet (always visible, even in single-column diff mode) -->
1112
+ {#snippet viewTabBar()}
1113
+ <div class="relative flex border-b border-slate-200 dark:border-slate-700">
1114
+ {#each viewTabs as tab (tab.id)}
1115
+ {@const isActive = activeView === tab.id}
1145
1116
  <button
1146
1117
  type="button"
1147
- class="flex items-center justify-center w-7 h-7 rounded-md transition-colors cursor-pointer border-none
1148
- {activeView === 'stash' || activeView === 'tags'
1149
- ? 'bg-violet-500/10 text-violet-600'
1150
- : 'bg-transparent text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'}"
1151
- onclick={() => showMoreMenu = !showMoreMenu}
1152
- title="More views"
1118
+ class="relative flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors {isActive
1119
+ ? 'text-violet-600 dark:text-violet-400'
1120
+ : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
1121
+ onclick={() => switchToView(tab.id)}
1153
1122
  >
1154
- <Icon name="lucide:ellipsis" class="w-4 h-4" />
1123
+ {tab.label}
1124
+ {#if tab.badge}
1125
+ <span class="min-w-4 h-4 px-1 rounded-full bg-violet-500/15 dark:bg-violet-500/25 text-3xs font-semibold flex items-center justify-center">{tab.badge}</span>
1126
+ {/if}
1127
+ {#if isActive}
1128
+ <span class="absolute bottom-0 inset-x-0 h-px bg-violet-600 dark:bg-violet-400"></span>
1129
+ {/if}
1155
1130
  </button>
1156
-
1157
- {#if showMoreMenu}
1158
- <div
1159
- class="fixed inset-0 z-40"
1160
- onclick={() => showMoreMenu = false}
1161
- ></div>
1162
- <div class="absolute right-0 top-full mt-1 z-50 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg py-1 min-w-32">
1163
- <button
1164
- type="button"
1165
- class="flex items-center gap-2 w-full px-3 py-1.5 text-xs font-medium text-left transition-colors cursor-pointer border-none
1166
- {activeView === 'stash'
1167
- ? 'bg-violet-500/10 text-violet-600'
1168
- : 'bg-transparent text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'}"
1169
- onclick={() => { switchToView('stash'); showMoreMenu = false; }}
1170
- >
1171
- <Icon name="lucide:archive" class="w-3.5 h-3.5" />
1172
- Stash
1173
- {#if stashEntries.length > 0}
1174
- <span class="ml-auto text-3xs font-semibold text-slate-400">{stashEntries.length}</span>
1175
- {/if}
1176
- </button>
1177
- <button
1178
- type="button"
1179
- class="flex items-center gap-2 w-full px-3 py-1.5 text-xs font-medium text-left transition-colors cursor-pointer border-none
1180
- {activeView === 'tags'
1181
- ? 'bg-violet-500/10 text-violet-600'
1182
- : 'bg-transparent text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'}"
1183
- onclick={() => { switchToView('tags'); showMoreMenu = false; }}
1184
- >
1185
- <Icon name="lucide:tag" class="w-3.5 h-3.5" />
1186
- Tags
1187
- {#if tags.length > 0}
1188
- <span class="ml-auto text-3xs font-semibold text-slate-400">{tags.length}</span>
1189
- {/if}
1190
- </button>
1191
- </div>
1192
- {/if}
1193
- </div>
1131
+ {/each}
1194
1132
  </div>
1133
+ {/snippet}
1195
1134
 
1135
+ <!-- Changes list snippet -->
1136
+ {#snippet changesList()}
1196
1137
  {#if activeView === 'changes'}
1197
1138
  <!-- Commit form -->
1198
1139
  <CommitForm
@@ -1253,7 +1194,7 @@
1253
1194
  />
1254
1195
  {:else if activeView === 'stash'}
1255
1196
  <!-- Stash View -->
1256
- <div class="flex-1 overflow-y-auto">
1197
+ <div class="flex-1 overflow-y-auto pt-2">
1257
1198
  <!-- Stash save button/form -->
1258
1199
  <div class="px-2 pb-2">
1259
1200
  {#if showStashSaveForm}
@@ -1337,7 +1278,7 @@
1337
1278
  </div>
1338
1279
  {:else if activeView === 'tags'}
1339
1280
  <!-- Tags View -->
1340
- <div class="flex-1 overflow-y-auto">
1281
+ <div class="flex-1 overflow-y-auto pt-2">
1341
1282
  <!-- Create tag button/form -->
1342
1283
  <div class="px-2 pb-2">
1343
1284
  {#if showCreateTagForm}
@@ -1502,16 +1443,17 @@
1502
1443
  <div class="flex-1 overflow-hidden">
1503
1444
  <!-- Unified layout: always render both panels to preserve state (like Files panel) -->
1504
1445
  <div class="h-full flex">
1505
- <!-- Left panel: Changes list (w-80 like Files panel tree) -->
1446
+ <!-- Left panel: Changes list -->
1506
1447
  <div
1507
1448
  class={isTwoColumnMode
1508
1449
  ? 'w-72 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700 flex flex-col'
1509
1450
  : (viewMode === 'list' ? 'w-full h-full overflow-hidden flex flex-col' : 'hidden')}
1510
1451
  >
1452
+ {@render viewTabBar()}
1511
1453
  {@render changesList()}
1512
1454
  </div>
1513
1455
 
1514
- <!-- Right panel: Diff viewer (like Files panel editor) -->
1456
+ <!-- Right panel: Diff viewer -->
1515
1457
  <div
1516
1458
  class={isTwoColumnMode
1517
1459
  ? 'flex-1 h-full overflow-hidden flex flex-col'
@@ -139,26 +139,72 @@ export function clearSessionProcessState(sessionId: string): void {
139
139
  // UNREAD SESSION MANAGEMENT
140
140
  // ========================================
141
141
 
142
+ /**
143
+ * Persist the current unread sessions Map to the server via user:save-state.
144
+ * Uses the same proven infrastructure as currentProjectId/lastView persistence.
145
+ * Debounced: only the last call within 500ms actually persists.
146
+ */
147
+ let saveUnreadTimeout: ReturnType<typeof setTimeout> | null = null;
148
+
149
+ function persistUnreadSessions(): void {
150
+ if (saveUnreadTimeout) clearTimeout(saveUnreadTimeout);
151
+ saveUnreadTimeout = setTimeout(() => {
152
+ const serialized = Object.fromEntries(appState.unreadSessions);
153
+ debug.log('session', '[unread] Persisting to server:', serialized);
154
+ ws.http('user:save-state', { key: 'unreadSessions', value: serialized }).catch(err => {
155
+ debug.error('session', '[unread] Error persisting unread sessions:', err);
156
+ });
157
+ }, 500);
158
+ }
159
+
142
160
  /**
143
161
  * Mark a session as unread (has new activity the user hasn't seen).
162
+ * Persists to backend so the state survives browser refresh.
144
163
  */
145
164
  export function markSessionUnread(sessionId: string, projectId: string): void {
146
165
  const next = new Map(appState.unreadSessions);
147
166
  next.set(sessionId, projectId);
148
167
  appState.unreadSessions = next;
168
+
169
+ // Persist to backend via user:save-state (proven infrastructure)
170
+ debug.log('session', `[unread] markSessionUnread: sessionId=${sessionId}, projectId=${projectId}`);
171
+ ws.emit('sessions:mark-unread', { sessionId, projectId });
172
+ persistUnreadSessions();
149
173
  }
150
174
 
151
175
  /**
152
176
  * Mark a session as read (user has viewed it).
177
+ * Persists to backend so the state survives browser refresh.
153
178
  */
154
179
  export function markSessionRead(sessionId: string): void {
155
180
  if (appState.unreadSessions.has(sessionId)) {
156
181
  const next = new Map(appState.unreadSessions);
157
182
  next.delete(sessionId);
158
183
  appState.unreadSessions = next;
184
+
185
+ // Persist to backend via user:save-state (proven infrastructure)
186
+ debug.log('session', `[unread] markSessionRead: sessionId=${sessionId}`);
187
+ ws.emit('sessions:mark-read', { sessionId });
188
+ persistUnreadSessions();
159
189
  }
160
190
  }
161
191
 
192
+ /**
193
+ * Restore unread sessions from server state (called during initialization).
194
+ */
195
+ export function restoreUnreadSessions(saved: Record<string, string> | null): void {
196
+ if (!saved || typeof saved !== 'object') return;
197
+
198
+ const next = new Map(appState.unreadSessions);
199
+ for (const [sessionId, projectId] of Object.entries(saved)) {
200
+ if (typeof sessionId === 'string' && typeof projectId === 'string') {
201
+ next.set(sessionId, projectId);
202
+ }
203
+ }
204
+ appState.unreadSessions = next;
205
+ debug.log('session', '[unread] Restored from server:', Object.fromEntries(appState.unreadSessions));
206
+ }
207
+
162
208
  /**
163
209
  * Check if a session is unread.
164
210
  */
@@ -13,7 +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
+ import { markSessionUnread, markSessionRead, appState } from '$frontend/lib/stores/core/app.svelte';
17
17
  import { debug } from '$shared/utils/logger';
18
18
 
19
19
  interface SessionState {
@@ -236,9 +236,20 @@ export async function loadSessions() {
236
236
  const response = await ws.http('sessions:list');
237
237
 
238
238
  if (response) {
239
- const { sessions, currentSessionId } = response;
239
+ const { sessions, currentSessionId, unreadSessionIds } = response;
240
240
  sessionState.sessions = sessions;
241
241
 
242
+ // Restore unread session state from backend
243
+ debug.log('session', '[unread] loadSessions received unreadSessionIds:', unreadSessionIds);
244
+ if (unreadSessionIds && Array.isArray(unreadSessionIds) && unreadSessionIds.length > 0) {
245
+ const next = new Map(appState.unreadSessions);
246
+ for (const { sessionId, projectId } of unreadSessionIds) {
247
+ next.set(sessionId, projectId);
248
+ }
249
+ appState.unreadSessions = next;
250
+ debug.log('session', '[unread] Restored unread sessions:', Array.from(appState.unreadSessions.entries()));
251
+ }
252
+
242
253
  // Auto-restore: find the active session for the current project
243
254
  if (!sessionState.currentSession) {
244
255
  const currentProject = projectState.currentProject;
@@ -310,7 +321,7 @@ export async function reloadSessionsForProject(): Promise<string | null> {
310
321
  try {
311
322
  const response = await ws.http('sessions:list');
312
323
  if (response) {
313
- const { sessions, currentSessionId } = response;
324
+ const { sessions, currentSessionId, unreadSessionIds } = response;
314
325
  // Merge: keep sessions from other projects, replace sessions for current project
315
326
  const currentProjectId = projectState.currentProject?.id;
316
327
  if (currentProjectId) {
@@ -321,6 +332,16 @@ export async function reloadSessionsForProject(): Promise<string | null> {
321
332
  } else {
322
333
  sessionState.sessions = sessions;
323
334
  }
335
+
336
+ // Restore unread session state from backend
337
+ if (unreadSessionIds && Array.isArray(unreadSessionIds)) {
338
+ const next = new Map(appState.unreadSessions);
339
+ for (const { sessionId, projectId } of unreadSessionIds) {
340
+ next.set(sessionId, projectId);
341
+ }
342
+ appState.unreadSessions = next;
343
+ }
344
+
324
345
  return currentSessionId || null;
325
346
  }
326
347
  } catch (error) {
@@ -459,20 +459,20 @@ const defaultPanels: Record<PanelId, PanelConfig> = {
459
459
  minimized: false,
460
460
  order: 0
461
461
  },
462
- preview: {
463
- id: 'preview',
464
- title: 'Preview',
465
- icon: 'lucide:globe',
466
- visible: true,
467
- minimized: false,
468
- order: 1
469
- },
470
462
  files: {
471
463
  id: 'files',
472
464
  title: 'Files',
473
465
  icon: 'lucide:folder',
474
466
  visible: true,
475
467
  minimized: false,
468
+ order: 1
469
+ },
470
+ git: {
471
+ id: 'git',
472
+ title: 'Source Control',
473
+ icon: 'lucide:git-branch',
474
+ visible: true,
475
+ minimized: false,
476
476
  order: 2
477
477
  },
478
478
  terminal: {
@@ -483,10 +483,10 @@ const defaultPanels: Record<PanelId, PanelConfig> = {
483
483
  minimized: false,
484
484
  order: 3
485
485
  },
486
- git: {
487
- id: 'git',
488
- title: 'Source Control',
489
- icon: 'lucide:git-branch',
486
+ preview: {
487
+ id: 'preview',
488
+ title: 'Preview',
489
+ icon: 'lucide:globe',
490
490
  visible: true,
491
491
  minimized: false,
492
492
  order: 4
@@ -496,9 +496,9 @@ const defaultPanels: Record<PanelId, PanelConfig> = {
496
496
  export const PANEL_OPTIONS: { id: PanelId; title: string; icon: IconName }[] = [
497
497
  { id: 'chat', title: 'AI Assistant', icon: 'lucide:bot' },
498
498
  { id: 'files', title: 'Files', icon: 'lucide:folder' },
499
- { id: 'preview', title: 'Preview', icon: 'lucide:globe' },
499
+ { id: 'git', title: 'Source Control', icon: 'lucide:git-branch' },
500
500
  { id: 'terminal', title: 'Terminal', icon: 'lucide:terminal' },
501
- { id: 'git', title: 'Source Control', icon: 'lucide:git-branch' }
501
+ { id: 'preview', title: 'Preview', icon: 'lucide:globe' }
502
502
  ];
503
503
 
504
504
  // Default: Main + Stack layout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",
@@ -62,7 +62,6 @@
62
62
  "@types/bun": "^1.2.18",
63
63
  "@types/node": "^24.0.14",
64
64
  "@types/qrcode": "^1.5.6",
65
- "@types/xterm": "^3.0.0",
66
65
  "concurrently": "^9.2.1",
67
66
  "eslint": "^9.31.0",
68
67
  "eslint-plugin-svelte": "^3.10.1",
@@ -83,8 +82,12 @@
83
82
  "@modelcontextprotocol/sdk": "^1.26.0",
84
83
  "@monaco-editor/loader": "^1.5.0",
85
84
  "@opencode-ai/sdk": "^1.2.15",
86
- "@xterm/addon-fit": "^0.10.0",
87
- "@xterm/addon-web-links": "^0.11.0",
85
+ "@xterm/addon-clipboard": "^0.2.0",
86
+ "@xterm/addon-fit": "^0.11.0",
87
+ "@xterm/addon-ligatures": "^0.10.0",
88
+ "@xterm/addon-unicode11": "^0.9.0",
89
+ "@xterm/addon-web-links": "^0.12.0",
90
+ "@xterm/xterm": "^6.0.0",
88
91
  "bun-pty": "^0.4.2",
89
92
  "cloudflared": "^0.7.1",
90
93
  "elysia": "^1.4.19",
@@ -95,8 +98,7 @@
95
98
  "nanoid": "^5.1.6",
96
99
  "puppeteer": "^24.33.0",
97
100
  "puppeteer-cluster": "^0.25.0",
98
- "qrcode": "^1.5.4",
99
- "xterm": "^5.3.0"
101
+ "qrcode": "^1.5.4"
100
102
  },
101
103
  "trustedDependencies": [
102
104
  "cloudflared",