@myrialabs/clopen 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +61 -27
  2. package/backend/chat/stream-manager.ts +114 -16
  3. package/backend/database/queries/project-queries.ts +1 -4
  4. package/backend/database/queries/session-queries.ts +36 -1
  5. package/backend/database/queries/snapshot-queries.ts +122 -0
  6. package/backend/database/utils/connection.ts +17 -11
  7. package/backend/engine/adapters/claude/stream.ts +12 -2
  8. package/backend/engine/adapters/opencode/stream.ts +37 -19
  9. package/backend/index.ts +18 -2
  10. package/backend/mcp/servers/browser-automation/browser.ts +2 -0
  11. package/backend/preview/browser/browser-mcp-control.ts +16 -0
  12. package/backend/preview/browser/browser-navigation-tracker.ts +31 -3
  13. package/backend/preview/browser/browser-preview-service.ts +0 -34
  14. package/backend/preview/browser/browser-video-capture.ts +13 -1
  15. package/backend/preview/browser/scripts/audio-stream.ts +5 -0
  16. package/backend/preview/browser/types.ts +7 -6
  17. package/backend/snapshot/blob-store.ts +52 -72
  18. package/backend/snapshot/snapshot-service.ts +24 -0
  19. package/backend/terminal/stream-manager.ts +41 -2
  20. package/backend/ws/chat/stream.ts +14 -7
  21. package/backend/ws/engine/claude/accounts.ts +6 -8
  22. package/backend/ws/preview/browser/interact.ts +46 -50
  23. package/backend/ws/preview/browser/webcodecs.ts +24 -15
  24. package/backend/ws/projects/crud.ts +72 -7
  25. package/backend/ws/sessions/crud.ts +119 -2
  26. package/backend/ws/system/operations.ts +14 -39
  27. package/frontend/components/auth/SetupPage.svelte +1 -1
  28. package/frontend/components/chat/input/ChatInput.svelte +14 -1
  29. package/frontend/components/chat/message/MessageBubble.svelte +13 -0
  30. package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
  31. package/frontend/components/common/form/FolderBrowser.svelte +17 -4
  32. package/frontend/components/common/overlay/Dialog.svelte +17 -15
  33. package/frontend/components/files/FileNode.svelte +16 -73
  34. package/frontend/components/git/CommitForm.svelte +1 -1
  35. package/frontend/components/history/HistoryModal.svelte +94 -19
  36. package/frontend/components/history/HistoryView.svelte +29 -36
  37. package/frontend/components/preview/browser/components/Canvas.svelte +119 -42
  38. package/frontend/components/preview/browser/components/Container.svelte +18 -3
  39. package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
  40. package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
  41. package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
  42. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
  43. package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
  44. package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
  45. package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
  46. package/frontend/components/workspace/MobileNavigator.svelte +57 -10
  47. package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
  48. package/frontend/services/chat/chat.service.ts +111 -16
  49. package/frontend/services/notification/global-stream-monitor.ts +5 -2
  50. package/frontend/services/notification/push.service.ts +2 -2
  51. package/frontend/services/preview/browser/browser-webcodecs.service.ts +170 -46
  52. package/frontend/stores/core/app.svelte.ts +10 -2
  53. package/frontend/stores/core/sessions.svelte.ts +4 -1
  54. package/package.json +2 -2
@@ -33,15 +33,13 @@ function stripAnsi(str: string): string {
33
33
  .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
34
34
  }
35
35
 
36
+ // Extracts the first https:// URL from PTY output.
37
+ // Known formats (may change across Claude Code versions):
38
+ // - https://claude.ai/oauth/authorize?...
39
+ // - https://claude.com/cai/oauth/authorize?...
36
40
  function extractAuthUrl(clean: string): string | null {
37
- const urlPrefix = 'https://claude.ai/oauth/authorize?';
38
- const urlStart = clean.indexOf(urlPrefix);
39
- if (urlStart === -1) return null;
40
-
41
- const pasteIdx = clean.indexOf('Paste', urlStart);
42
- if (pasteIdx === -1) return null;
43
-
44
- return clean.substring(urlStart, pasteIdx).replace(/\s/g, '');
41
+ const match = clean.match(/https:\/\/\S+/);
42
+ return match ? match[0] : null;
45
43
  }
46
44
 
47
45
  function extractOAuthToken(clean: string): string | null {
@@ -12,6 +12,9 @@ import type { KeyInput } from 'puppeteer';
12
12
  import { debug } from '$shared/utils/logger';
13
13
  import { sleep } from '$shared/utils/async';
14
14
 
15
+ // Throttle cursor detection evaluate calls per session (100ms = ~10/sec is plenty)
16
+ const lastCursorEvalTime = new Map<string, number>();
17
+
15
18
  // Helper function to check if error is navigation-related
16
19
  function isNavigationError(error: Error): boolean {
17
20
  const msg = error.message.toLowerCase();
@@ -108,8 +111,9 @@ export const interactPreviewHandler = createRouter()
108
111
  switch (action.type) {
109
112
  case 'mousedown':
110
113
  try {
111
- // Reset mouse state first to ensure clean state
112
- try { await session.page.mouse.up(); } catch { }
114
+ // Fire-and-forget reset CDP processes commands in FIFO order so
115
+ // this completes before move/down even though we skip the await.
116
+ session.page.mouse.up().catch(() => {});
113
117
  // Move to position and press button
114
118
  await session.page.mouse.move(action.x!, action.y!, { steps: 1 });
115
119
  await session.page.mouse.down({ button: action.button === 'right' ? 'right' : 'left' });
@@ -140,14 +144,10 @@ export const interactPreviewHandler = createRouter()
140
144
 
141
145
  case 'click':
142
146
  try {
143
- // Reset mouse state before click to prevent "already pressed" errors
144
- // This ensures a clean state for each click operation
145
- try {
146
- await session.page.mouse.up();
147
- } catch { /* Ignore - mouse might not be pressed */ }
148
-
149
- // IMPORTANT: Check for select element BEFORE clicking
150
- // If it's a select, we'll emit event to frontend instead of clicking
147
+ // Check for select element BEFORE clicking.
148
+ // Skip the mouse.up() reset: page.mouse.click() is atomic (down+up),
149
+ // and Canvas.svelte always sends mouseup before sending click, so the
150
+ // mouse state is already clean at this point.
151
151
  const selectInfo = await previewService.checkForSelectElement(session.id, action.x!, action.y!);
152
152
  if (selectInfo) {
153
153
  // Select element detected - event emitted by checkForSelectElement
@@ -243,37 +243,40 @@ export const interactPreviewHandler = createRouter()
243
243
  await session.page.mouse.move(action.x!, action.y!, {
244
244
  steps: action.steps || 1 // Reduced from 5 to 1 for faster response
245
245
  });
246
- // Update cursor position and detect cursor type in browser context (fire-and-forget, don't await)
247
- // This replaces the disabled cursor-tracking script (blocked by CloudFlare)
248
- session.page.evaluate((data) => {
249
- const { x, y } = data;
250
- // Detect cursor type from element under mouse
251
- let cursor = 'default';
252
- try {
253
- const el = document.elementFromPoint(x, y);
254
- if (el) {
255
- cursor = window.getComputedStyle(el).cursor || 'default';
246
+ // Cursor detection via page.evaluate throttled to ~10/sec per session.
247
+ // Running it on every mousemove queues extra CDP commands that delay clicks/keypresses.
248
+ const nowMs = Date.now();
249
+ const lastEval = lastCursorEvalTime.get(session.id) ?? 0;
250
+ if (nowMs - lastEval >= 100) {
251
+ lastCursorEvalTime.set(session.id, nowMs);
252
+ session.page.evaluate((data) => {
253
+ const { x, y } = data;
254
+ let cursor = 'default';
255
+ try {
256
+ const el = document.elementFromPoint(x, y);
257
+ if (el) {
258
+ cursor = window.getComputedStyle(el).cursor || 'default';
259
+ }
260
+ } catch {}
261
+
262
+ const existing = (window as any).__cursorInfo;
263
+ if (existing) {
264
+ existing.cursor = cursor;
265
+ existing.x = x;
266
+ existing.y = y;
267
+ existing.timestamp = Date.now();
268
+ existing.hasRecentInteraction = true;
269
+ } else {
270
+ (window as any).__cursorInfo = {
271
+ cursor,
272
+ x,
273
+ y,
274
+ timestamp: Date.now(),
275
+ hasRecentInteraction: true
276
+ };
256
277
  }
257
- } catch {}
258
-
259
- // Initialize or update __cursorInfo
260
- const existing = (window as any).__cursorInfo;
261
- if (existing) {
262
- existing.cursor = cursor;
263
- existing.x = x;
264
- existing.y = y;
265
- existing.timestamp = Date.now();
266
- existing.hasRecentInteraction = true;
267
- } else {
268
- (window as any).__cursorInfo = {
269
- cursor,
270
- x,
271
- y,
272
- timestamp: Date.now(),
273
- hasRecentInteraction: true
274
- };
275
- }
276
- }, { x: action.x!, y: action.y! }).catch(() => { /* Ignore evaluation errors */ });
278
+ }, { x: action.x!, y: action.y! }).catch(() => { /* Ignore evaluation errors */ });
279
+ }
277
280
  } catch (error) {
278
281
  if (error instanceof Error && isNavigationError(error)) {
279
282
  ws.emit.user(userId, 'preview:browser-interacted', { action: action.type, message: 'Action deferred (navigation)', deferred: true });
@@ -297,8 +300,6 @@ export const interactPreviewHandler = createRouter()
297
300
 
298
301
  case 'doubleclick':
299
302
  try {
300
- // Reset mouse state first
301
- try { await session.page.mouse.up(); } catch { }
302
303
  await session.page.mouse.click(action.x!, action.y!, { clickCount: 2 });
303
304
  } catch (error) {
304
305
  if (error instanceof Error) {
@@ -314,8 +315,8 @@ export const interactPreviewHandler = createRouter()
314
315
 
315
316
  case 'rightclick':
316
317
  try {
317
- // Reset mouse state first
318
- try { await session.page.mouse.up(); } catch { }
318
+ // Fire-and-forget reset (see mousedown comment for rationale)
319
+ session.page.mouse.up().catch(() => {});
319
320
 
320
321
  // IMPORTANT: Check for context menu
321
322
  // We'll emit context menu event to frontend for custom overlay
@@ -385,12 +386,7 @@ export const interactPreviewHandler = createRouter()
385
386
  await session.page.keyboard.press(action.key as KeyInput);
386
387
  }
387
388
 
388
- if (['ArrowDown', 'ArrowUp'].includes(action.key)) {
389
- try {
390
- await sleep(50);
391
- } catch { }
392
- }
393
- }
389
+ }
394
390
  break;
395
391
 
396
392
  case 'checkselectoptions':
@@ -16,7 +16,9 @@ export const streamPreviewHandler = createRouter()
16
16
  .http(
17
17
  'preview:browser-stream-start',
18
18
  {
19
- data: t.Object({}),
19
+ data: t.Object({
20
+ tabId: t.Optional(t.String())
21
+ }),
20
22
  response: t.Object({
21
23
  success: t.Boolean(),
22
24
  message: t.Optional(t.String()),
@@ -34,9 +36,10 @@ export const streamPreviewHandler = createRouter()
34
36
  // Get project-specific preview service
35
37
  const previewService = browserPreviewServiceManager.getService(projectId);
36
38
 
37
- const tab = previewService.getActiveTab();
39
+ // Use explicit tabId if provided, otherwise fall back to active tab
40
+ const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
38
41
  if (!tab) {
39
- throw new Error('No active tab');
42
+ throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
40
43
  }
41
44
 
42
45
  const sessionId = tab.id;
@@ -73,7 +76,9 @@ export const streamPreviewHandler = createRouter()
73
76
  .http(
74
77
  'preview:browser-stream-offer',
75
78
  {
76
- data: t.Object({}),
79
+ data: t.Object({
80
+ tabId: t.Optional(t.String())
81
+ }),
77
82
  response: t.Object({
78
83
  success: t.Boolean(),
79
84
  offer: t.Optional(
@@ -90,9 +95,9 @@ export const streamPreviewHandler = createRouter()
90
95
  // Get project-specific preview service
91
96
  const previewService = browserPreviewServiceManager.getService(projectId);
92
97
 
93
- const tab = previewService.getActiveTab();
98
+ const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
94
99
  if (!tab) {
95
- throw new Error('No active tab');
100
+ throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
96
101
  }
97
102
 
98
103
  const offer = await previewService.getWebCodecsOffer(tab.id);
@@ -117,7 +122,8 @@ export const streamPreviewHandler = createRouter()
117
122
  answer: t.Object({
118
123
  type: t.String(),
119
124
  sdp: t.Optional(t.String())
120
- })
125
+ }),
126
+ tabId: t.Optional(t.String())
121
127
  }),
122
128
  response: t.Object({
123
129
  success: t.Boolean()
@@ -129,9 +135,9 @@ export const streamPreviewHandler = createRouter()
129
135
  // Get project-specific preview service
130
136
  const previewService = browserPreviewServiceManager.getService(projectId);
131
137
 
132
- const tab = previewService.getActiveTab();
138
+ const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
133
139
  if (!tab) {
134
- throw new Error('No active tab');
140
+ throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
135
141
  }
136
142
 
137
143
  const { answer } = data;
@@ -150,7 +156,8 @@ export const streamPreviewHandler = createRouter()
150
156
  candidate: t.Optional(t.String()),
151
157
  sdpMid: t.Optional(t.Union([t.String(), t.Null()])),
152
158
  sdpMLineIndex: t.Optional(t.Union([t.Number(), t.Null()]))
153
- })
159
+ }),
160
+ tabId: t.Optional(t.String())
154
161
  }),
155
162
  response: t.Object({
156
163
  success: t.Boolean()
@@ -162,9 +169,9 @@ export const streamPreviewHandler = createRouter()
162
169
  // Get project-specific preview service
163
170
  const previewService = browserPreviewServiceManager.getService(projectId);
164
171
 
165
- const tab = previewService.getActiveTab();
172
+ const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
166
173
  if (!tab) {
167
- throw new Error('No active tab');
174
+ throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
168
175
  }
169
176
 
170
177
  const { candidate } = data;
@@ -178,7 +185,9 @@ export const streamPreviewHandler = createRouter()
178
185
  .http(
179
186
  'preview:browser-stream-stop',
180
187
  {
181
- data: t.Object({}),
188
+ data: t.Object({
189
+ tabId: t.Optional(t.String())
190
+ }),
182
191
  response: t.Object({
183
192
  success: t.Boolean()
184
193
  })
@@ -189,9 +198,9 @@ export const streamPreviewHandler = createRouter()
189
198
  // Get project-specific preview service
190
199
  const previewService = browserPreviewServiceManager.getService(projectId);
191
200
 
192
- const tab = previewService.getActiveTab();
201
+ const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
193
202
  if (!tab) {
194
- throw new Error('No active tab');
203
+ throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
195
204
  }
196
205
 
197
206
  await previewService.stopWebCodecsStreaming(tab.id);
@@ -12,8 +12,14 @@
12
12
  import { t } from 'elysia';
13
13
  import { createRouter } from '$shared/utils/ws-server';
14
14
  import { initializeDatabase } from '../../database';
15
- import { projectQueries } from '../../database/queries';
15
+ import { projectQueries, sessionQueries, snapshotQueries } from '../../database/queries';
16
16
  import { ws } from '$backend/utils/ws';
17
+ import { snapshotService } from '../../snapshot/snapshot-service';
18
+ import { blobStore } from '../../snapshot/blob-store';
19
+ import { streamManager } from '../../chat/stream-manager';
20
+ import { terminalStreamManager } from '../../terminal/stream-manager';
21
+ import { broadcastPresence } from '../projects/status';
22
+ import { debug } from '$shared/utils/logger';
17
23
 
18
24
  export const crudHandler = createRouter()
19
25
  // List all projects for the current user
@@ -81,10 +87,11 @@ export const crudHandler = createRouter()
81
87
  return updatedProject;
82
88
  })
83
89
 
84
- // Delete project (remove user association, cleanup if orphaned)
90
+ // Delete project (remove from list or full delete with sessions)
85
91
  .http('projects:delete', {
86
92
  data: t.Object({
87
- id: t.String({ minLength: 1 })
93
+ id: t.String({ minLength: 1 }),
94
+ mode: t.Optional(t.Union([t.Literal('remove'), t.Literal('full')]))
88
95
  }),
89
96
  response: t.Object({
90
97
  id: t.String(),
@@ -97,15 +104,73 @@ export const crudHandler = createRouter()
97
104
  throw new Error('Project not found');
98
105
  }
99
106
 
107
+ const mode = data.mode ?? 'remove';
108
+
109
+ // Clean up terminal cache for this project
110
+ const cachedTerminals = terminalStreamManager.cleanupProjectCache(data.id);
111
+ debug.log('project', `Cleaned up ${cachedTerminals} terminal cache files for project ${data.id}`);
112
+
113
+ if (mode === 'full') {
114
+ // Full delete: remove sessions with blob cleanup, then the project itself
115
+ const sessions = sessionQueries.getByProjectId(data.id);
116
+
117
+ if (sessions.length > 0) {
118
+ // Cancel active chat streams
119
+ await Promise.all(
120
+ sessions.map(s => streamManager.cleanupSessionStreams(s.id).catch(() => {}))
121
+ );
122
+
123
+ // Collect blob hashes before deleting
124
+ const baselineHashes = new Set<string>();
125
+ for (const s of sessions) {
126
+ for (const h of snapshotService.getSessionBaselineHashes(s.id)) {
127
+ baselineHashes.add(h);
128
+ }
129
+ }
130
+ const allSnapshots = snapshotQueries.getAllByProjectId(data.id);
131
+ const deltaHashes = snapshotQueries.collectBlobHashes(allSnapshots);
132
+
133
+ // Clear in-memory baselines
134
+ for (const s of sessions) {
135
+ snapshotService.clearSessionBaseline(s.id);
136
+ }
137
+
138
+ // Delete all sessions and related DB data
139
+ const deletedIds = sessionQueries.deleteAllByProjectId(data.id);
140
+
141
+ // GC orphaned blobs
142
+ const allBlobsOnDisk = await blobStore.scanAllBlobHashes();
143
+ const stillReferencedByDB = snapshotQueries.getAllReferencedBlobHashes();
144
+ const stillReferencedByMemory = snapshotService.getAllBaselineHashes();
145
+ const blobsToDelete = [...allBlobsOnDisk].filter(
146
+ h => !stillReferencedByDB.has(h) && !stillReferencedByMemory.has(h)
147
+ );
148
+ if (blobsToDelete.length > 0) {
149
+ const deleted = await blobStore.deleteBlobs(blobsToDelete);
150
+ debug.log('project', `Cleaned up ${deleted}/${blobsToDelete.length} orphaned blobs`);
151
+ }
152
+
153
+ // Broadcast session deletions
154
+ for (const sessionId of deletedIds) {
155
+ ws.emit.project(data.id, 'sessions:session-deleted', { sessionId, projectId: data.id });
156
+ }
157
+ }
158
+ }
159
+
100
160
  // Remove user's association with the project
101
161
  projectQueries.removeUserProject(userId, data.id);
102
162
 
103
- // If no more users are associated, delete the project entirely
104
- const remainingUsers = projectQueries.getUserCountForProject(data.id);
105
- if (remainingUsers === 0) {
106
- projectQueries.delete(data.id);
163
+ // In full mode, delete the project record if no users remain
164
+ // In remove mode, keep the project record so sessions can be restored
165
+ if (mode === 'full') {
166
+ const remainingUsers = projectQueries.getUserCountForProject(data.id);
167
+ if (remainingUsers === 0) {
168
+ projectQueries.deleteProject(data.id);
169
+ }
107
170
  }
108
171
 
172
+ broadcastPresence().catch(() => {});
173
+
109
174
  return {
110
175
  id: data.id,
111
176
  deleted: true
@@ -13,8 +13,12 @@
13
13
  import { t } from 'elysia';
14
14
  import { createRouter } from '$shared/utils/ws-server';
15
15
  import type { EngineType } from '$shared/types/engine';
16
- import { sessionQueries, messageQueries, projectQueries } from '../../database/queries';
16
+ import { sessionQueries, messageQueries, projectQueries, snapshotQueries } from '../../database/queries';
17
17
  import { ws } from '$backend/utils/ws';
18
+ import { streamManager } from '../../chat/stream-manager';
19
+ import { snapshotService } from '../../snapshot/snapshot-service';
20
+ import { blobStore } from '../../snapshot/blob-store';
21
+ import { broadcastPresence } from '../projects/status';
18
22
  import { debug } from '$shared/utils/logger';
19
23
 
20
24
  export const crudHandler = createRouter()
@@ -293,7 +297,7 @@ export const crudHandler = createRouter()
293
297
  };
294
298
  })
295
299
 
296
- // Delete session
300
+ // Delete session (with full related data cleanup)
297
301
  .http('sessions:delete', {
298
302
  data: t.Object({
299
303
  id: t.String({ minLength: 1 })
@@ -309,8 +313,40 @@ export const crudHandler = createRouter()
309
313
  }
310
314
 
311
315
  const projectId = session.project_id;
316
+
317
+ // Cancel and clean up any active/completed streams for this session
318
+ await streamManager.cleanupSessionStreams(data.id);
319
+
320
+ // Collect ALL blob hashes before deleting anything:
321
+ // 1. In-memory baseline hashes (all project files hashed at session start)
322
+ const baselineHashes = snapshotService.getSessionBaselineHashes(data.id);
323
+ // 2. DB snapshot delta hashes (including soft-deleted snapshots)
324
+ const allSnapshots = snapshotQueries.getAllBySessionId(data.id);
325
+ const deltaHashes = snapshotQueries.collectBlobHashes(allSnapshots);
326
+ // Combine both sources
327
+ const hashesToCheck = new Set([...baselineHashes, ...deltaHashes]);
328
+
329
+ debug.log('session', `Session ${data.id}: ${baselineHashes.size} baseline hashes, ${deltaHashes.size} delta hashes, ${hashesToCheck.size} total unique`);
330
+
331
+ // Clear in-memory snapshot baseline
332
+ snapshotService.clearSessionBaseline(data.id);
333
+
334
+ // Delete session and all related DB data (messages, snapshots, branches, relationships, unread)
312
335
  sessionQueries.delete(data.id);
313
336
 
337
+ // Clean up orphaned blobs — protect hashes still used by other active sessions
338
+ if (hashesToCheck.size > 0) {
339
+ const stillReferencedByDB = snapshotQueries.getAllReferencedBlobHashes();
340
+ const stillReferencedByMemory = snapshotService.getAllBaselineHashes();
341
+ const orphanHashes = [...hashesToCheck].filter(
342
+ h => !stillReferencedByDB.has(h) && !stillReferencedByMemory.has(h)
343
+ );
344
+ if (orphanHashes.length > 0) {
345
+ const deleted = await blobStore.deleteBlobs(orphanHashes);
346
+ debug.log('session', `Cleaned up ${deleted}/${orphanHashes.length} orphaned blobs`);
347
+ }
348
+ }
349
+
314
350
  // Broadcast to all project members so other users see the deletion
315
351
  debug.log('session', `Broadcasting session deleted: ${data.id} in project: ${projectId}`);
316
352
  ws.emit.project(projectId, 'sessions:session-deleted', {
@@ -318,11 +354,92 @@ export const crudHandler = createRouter()
318
354
  projectId
319
355
  });
320
356
 
357
+ // Broadcast updated presence so status indicators reflect the deletion
358
+ broadcastPresence().catch(() => {});
359
+
321
360
  return {
322
361
  message: 'Session deleted successfully'
323
362
  };
324
363
  })
325
364
 
365
+ // Delete all sessions for the current project (with full cleanup)
366
+ .http('sessions:delete-all', {
367
+ data: t.Object({}),
368
+ response: t.Object({
369
+ message: t.String(),
370
+ deletedCount: t.Number()
371
+ })
372
+ }, async ({ conn }) => {
373
+ const projectId = ws.getProjectId(conn);
374
+
375
+ // Get all sessions for this project to clean up streams
376
+ const sessions = sessionQueries.getByProjectId(projectId);
377
+ if (sessions.length === 0) {
378
+ return { message: 'No sessions to delete', deletedCount: 0 };
379
+ }
380
+
381
+ // Cancel and clean up active streams for all sessions
382
+ await Promise.all(
383
+ sessions.map(s => streamManager.cleanupSessionStreams(s.id).catch(() => {}))
384
+ );
385
+
386
+ // Collect ALL blob hashes before deleting anything:
387
+ // 1. In-memory baseline hashes from all sessions
388
+ const baselineHashes = new Set<string>();
389
+ for (const s of sessions) {
390
+ for (const h of snapshotService.getSessionBaselineHashes(s.id)) {
391
+ baselineHashes.add(h);
392
+ }
393
+ }
394
+ // 2. DB snapshot delta hashes (including soft-deleted)
395
+ const allSnapshots = snapshotQueries.getAllByProjectId(projectId);
396
+ const deltaHashes = snapshotQueries.collectBlobHashes(allSnapshots);
397
+ // Combine both sources
398
+ const hashesToCheck = new Set([...baselineHashes, ...deltaHashes]);
399
+
400
+ debug.log('session', `Project ${projectId}: ${baselineHashes.size} baseline hashes, ${deltaHashes.size} delta hashes, ${hashesToCheck.size} total unique`);
401
+
402
+ // Clear in-memory snapshot baselines for all sessions
403
+ for (const s of sessions) {
404
+ snapshotService.clearSessionBaseline(s.id);
405
+ }
406
+
407
+ // Delete all sessions and related DB data (messages, snapshots, branches, relationships, unread)
408
+ const deletedIds = sessionQueries.deleteAllByProjectId(projectId);
409
+
410
+ // Clean up orphaned blobs using mark-and-sweep GC:
411
+ // Scan ALL blobs on disk, keep those still referenced by remaining DB snapshots
412
+ // or by in-memory baselines of other active sessions (other projects)
413
+ const allBlobsOnDisk = await blobStore.scanAllBlobHashes();
414
+ const stillReferencedByDB = snapshotQueries.getAllReferencedBlobHashes();
415
+ const stillReferencedByMemory = snapshotService.getAllBaselineHashes();
416
+
417
+ const blobsToDelete = [...allBlobsOnDisk].filter(
418
+ h => !stillReferencedByDB.has(h) && !stillReferencedByMemory.has(h)
419
+ );
420
+
421
+ if (blobsToDelete.length > 0) {
422
+ const deleted = await blobStore.deleteBlobs(blobsToDelete);
423
+ debug.log('session', `Cleaned up ${deleted}/${blobsToDelete.length} orphaned blobs (full GC after bulk delete)`);
424
+ }
425
+
426
+ // Broadcast deletion for each session so all connected users update their state
427
+ for (const sessionId of deletedIds) {
428
+ ws.emit.project(projectId, 'sessions:session-deleted', {
429
+ sessionId,
430
+ projectId
431
+ });
432
+ }
433
+
434
+ broadcastPresence().catch(() => {});
435
+
436
+ debug.log('session', `Deleted all ${deletedIds.length} sessions in project: ${projectId}`);
437
+ return {
438
+ message: `Deleted ${deletedIds.length} sessions`,
439
+ deletedCount: deletedIds.length
440
+ };
441
+ })
442
+
326
443
  // Persist user's current session choice (for refresh restore)
327
444
  .on('sessions:set-current', {
328
445
  data: t.Object({
@@ -12,7 +12,7 @@ import { join } from 'node:path';
12
12
  import { readFileSync } from 'node:fs';
13
13
  import fs from 'node:fs/promises';
14
14
  import { createRouter } from '$shared/utils/ws-server';
15
- import { initializeDatabase, getDatabase } from '../../database';
15
+ import { closeDatabase, initializeDatabase } from '../../database';
16
16
  import { debug } from '$shared/utils/logger';
17
17
  import { ws } from '$backend/utils/ws';
18
18
  import { getClopenDir } from '$backend/utils/index';
@@ -143,51 +143,26 @@ export const operationsHandler = createRouter()
143
143
  tablesCount: t.Number()
144
144
  })
145
145
  }, async () => {
146
- debug.log('server', 'Clearing all database data...');
146
+ debug.log('server', 'Clearing all data...');
147
147
 
148
- // Initialize database first to ensure it exists
149
- await initializeDatabase();
150
-
151
- // Get database connection
152
- const db = getDatabase();
153
-
154
- // Get all table names
155
- const tables = db.prepare(`
156
- SELECT name FROM sqlite_master
157
- WHERE type='table'
158
- AND name NOT LIKE 'sqlite_%'
159
- `).all() as { name: string }[];
148
+ // Close database connection
149
+ closeDatabase();
160
150
 
161
- // Delete all data from each table
162
- for (const table of tables) {
163
- db.prepare(`DELETE FROM ${table.name}`).run();
164
- debug.log('server', `Cleared table: ${table.name}`);
165
- }
151
+ // Delete entire clopen directory
152
+ const clopenDir = getClopenDir();
153
+ await fs.rm(clopenDir, { recursive: true, force: true });
154
+ debug.log('server', 'Deleted clopen directory:', clopenDir);
166
155
 
167
- debug.log('server', 'Database cleared successfully');
156
+ // Reset environment state
157
+ resetEnvironment();
168
158
 
169
- // Delete snapshots directory
170
- const clopenDir = getClopenDir();
171
- const snapshotsDir = join(clopenDir, 'snapshots');
172
- try {
173
- await fs.rm(snapshotsDir, { recursive: true, force: true });
174
- debug.log('server', 'Snapshots directory cleared');
175
- } catch (err) {
176
- debug.warn('server', 'Failed to clear snapshots directory:', err);
177
- }
159
+ // Reinitialize database from scratch (creates dir, db, migrations, seeders)
160
+ await initializeDatabase();
178
161
 
179
- // Delete Claude config directory and reset environment state
180
- const claudeDir = join(clopenDir, 'claude');
181
- try {
182
- await fs.rm(claudeDir, { recursive: true, force: true });
183
- resetEnvironment();
184
- debug.log('server', 'Claude config directory cleared');
185
- } catch (err) {
186
- debug.warn('server', 'Failed to clear Claude config directory:', err);
187
- }
162
+ debug.log('server', 'All data cleared successfully');
188
163
 
189
164
  return {
190
165
  cleared: true,
191
- tablesCount: tables.length
166
+ tablesCount: 0
192
167
  };
193
168
  });
@@ -702,7 +702,7 @@
702
702
  class="flex items-center gap-1 px-2.5 py-1.5 text-2xs font-medium rounded-md bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 hover:bg-violet-200 dark:hover:bg-violet-800/40 transition-colors"
703
703
  >
704
704
  <Icon name="lucide:external-link" class="w-3 h-3" />
705
- Open
705
+ Open in Browser
706
706
  </a>
707
707
  </div>
708
708
 
@@ -25,7 +25,7 @@
25
25
  import { editModeState } from '$frontend/stores/ui/edit-mode.svelte';
26
26
  import { claudeAccountsStore } from '$frontend/stores/features/claude-accounts.svelte';
27
27
  import type { IconName } from '$shared/types/ui/icons';
28
- import ws from '$frontend/utils/ws';
28
+ import ws, { onWsReconnect } from '$frontend/utils/ws';
29
29
  import { debug } from '$shared/utils/logger';
30
30
 
31
31
  // Components
@@ -182,6 +182,19 @@
182
182
  // Also fetch partial text and reconnect to stream for late-joining users / refresh
183
183
  let lastCatchupProjectId: string | undefined;
184
184
  let lastPresenceProjectId: string | undefined;
185
+
186
+ // Reset catchup tracking on WS reconnect so catchupActiveStream re-runs.
187
+ // When WS briefly disconnects, the server-side cleanup removes the stream
188
+ // EventEmitter subscription. Without resetting, catchup won't fire again
189
+ // (guarded by lastCatchupProjectId) and the stream subscription is never
190
+ // re-established — causing stream output to silently stop in the UI.
191
+ onWsReconnect(() => {
192
+ if (lastCatchupProjectId) {
193
+ debug.log('chat', 'WS reconnected — resetting stream catchup tracking');
194
+ lastCatchupProjectId = undefined;
195
+ }
196
+ });
197
+
185
198
  $effect(() => {
186
199
  const projectId = projectState.currentProject?.id;
187
200
  const sessionId = sessionState.currentSession?.id; // Reactive dep: retry catchup when session loads