@myrialabs/clopen 0.2.11 → 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 (33) hide show
  1. package/backend/chat/stream-manager.ts +103 -9
  2. package/backend/database/queries/project-queries.ts +1 -4
  3. package/backend/database/queries/session-queries.ts +36 -1
  4. package/backend/database/queries/snapshot-queries.ts +122 -0
  5. package/backend/database/utils/connection.ts +17 -11
  6. package/backend/engine/adapters/claude/stream.ts +12 -2
  7. package/backend/index.ts +13 -2
  8. package/backend/snapshot/blob-store.ts +52 -72
  9. package/backend/snapshot/snapshot-service.ts +24 -0
  10. package/backend/terminal/stream-manager.ts +41 -2
  11. package/backend/ws/chat/stream.ts +14 -7
  12. package/backend/ws/engine/claude/accounts.ts +6 -8
  13. package/backend/ws/projects/crud.ts +72 -7
  14. package/backend/ws/sessions/crud.ts +119 -2
  15. package/backend/ws/system/operations.ts +14 -39
  16. package/frontend/components/auth/SetupPage.svelte +1 -1
  17. package/frontend/components/chat/input/ChatInput.svelte +14 -1
  18. package/frontend/components/chat/message/MessageBubble.svelte +13 -0
  19. package/frontend/components/common/form/FolderBrowser.svelte +17 -4
  20. package/frontend/components/common/overlay/Dialog.svelte +17 -15
  21. package/frontend/components/files/FileNode.svelte +0 -15
  22. package/frontend/components/history/HistoryModal.svelte +94 -19
  23. package/frontend/components/history/HistoryView.svelte +29 -36
  24. package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
  25. package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
  26. package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
  27. package/frontend/components/workspace/MobileNavigator.svelte +57 -10
  28. package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
  29. package/frontend/services/chat/chat.service.ts +86 -13
  30. package/frontend/services/notification/global-stream-monitor.ts +5 -2
  31. package/frontend/stores/core/app.svelte.ts +10 -2
  32. package/frontend/stores/core/sessions.svelte.ts +4 -1
  33. package/package.json +1 -1
@@ -691,6 +691,30 @@ export class SnapshotService {
691
691
  return calculateFileChangeStats(previousSnapshot, currentSnapshot);
692
692
  }
693
693
 
694
+ /**
695
+ * Get all blob hashes from the in-memory baseline for a session.
696
+ * Must be called BEFORE clearSessionBaseline.
697
+ */
698
+ getSessionBaselineHashes(sessionId: string): Set<string> {
699
+ const baseline = this.sessionBaselines.get(sessionId);
700
+ if (!baseline) return new Set();
701
+ return new Set(Object.values(baseline));
702
+ }
703
+
704
+ /**
705
+ * Get all blob hashes from ALL in-memory baselines (all active sessions).
706
+ * Used to protect blobs still needed by other sessions during cleanup.
707
+ */
708
+ getAllBaselineHashes(): Set<string> {
709
+ const hashes = new Set<string>();
710
+ for (const baseline of this.sessionBaselines.values()) {
711
+ for (const hash of Object.values(baseline)) {
712
+ hashes.add(hash);
713
+ }
714
+ }
715
+ return hashes;
716
+ }
717
+
694
718
  /**
695
719
  * Clean up session baseline cache when session is no longer active.
696
720
  */
@@ -4,8 +4,9 @@
4
4
  */
5
5
 
6
6
  import type { IPty } from 'bun-pty';
7
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs';
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from 'fs';
8
8
  import { join } from 'path';
9
+ import { getClopenDir } from '../utils/paths';
9
10
 
10
11
  interface TerminalStream {
11
12
  streamId: string;
@@ -25,7 +26,7 @@ interface TerminalStream {
25
26
  class TerminalStreamManager {
26
27
  private streams: Map<string, TerminalStream> = new Map();
27
28
  private sessionToStream: Map<string, string> = new Map();
28
- private tempDir: string = '.terminal-output-cache';
29
+ private tempDir: string = join(getClopenDir(), 'terminal-cache');
29
30
 
30
31
  constructor() {
31
32
  // Create temp directory for output caching
@@ -296,6 +297,44 @@ class TerminalStreamManager {
296
297
  };
297
298
  }
298
299
 
300
+ /**
301
+ * Clean up terminal cache files for a specific project
302
+ */
303
+ cleanupProjectCache(projectId: string): number {
304
+ let deleted = 0;
305
+ try {
306
+ const files = readdirSync(this.tempDir);
307
+ for (const file of files) {
308
+ if (!file.endsWith('.json')) continue;
309
+ try {
310
+ const filePath = join(this.tempDir, file);
311
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
312
+ if (data.projectId === projectId) {
313
+ unlinkSync(filePath);
314
+ deleted++;
315
+ }
316
+ } catch {
317
+ // Skip unreadable files
318
+ }
319
+ }
320
+ } catch {
321
+ // Directory may not exist
322
+ }
323
+
324
+ // Also remove in-memory streams for this project
325
+ for (const [streamId, stream] of this.streams) {
326
+ if (stream.projectId === projectId) {
327
+ if (stream.status === 'active' && stream.pty) {
328
+ try { stream.pty.kill(); } catch {}
329
+ }
330
+ this.streams.delete(streamId);
331
+ this.sessionToStream.delete(stream.sessionId);
332
+ }
333
+ }
334
+
335
+ return deleted;
336
+ }
337
+
299
338
  /**
300
339
  * Clean up all streams
301
340
  */
@@ -22,11 +22,11 @@ import { sessionQueries, messageQueries } from '../../database/queries';
22
22
  // exists (e.g., after browser refresh when user is on a different project).
23
23
  // Ensures cross-project notifications (presence update, sound, push) always work.
24
24
  // ============================================================================
25
- streamManager.on('stream:lifecycle', (event: { status: string; streamId: string; projectId?: string; chatSessionId?: string; timestamp: string }) => {
26
- const { status, projectId, chatSessionId, timestamp } = event;
25
+ streamManager.on('stream:lifecycle', (event: { status: string; streamId: string; projectId?: string; chatSessionId?: string; timestamp: string; reason?: string }) => {
26
+ const { status, projectId, chatSessionId, timestamp, reason } = event;
27
27
  if (!projectId) return;
28
28
 
29
- debug.log('chat', `Stream lifecycle: ${status} for project ${projectId} session ${chatSessionId}`);
29
+ debug.log('chat', `Stream lifecycle: ${status} for project ${projectId} session ${chatSessionId}${reason ? ` (reason: ${reason})` : ''}`);
30
30
 
31
31
  // Mark any tool_use blocks that never got a tool_result as interrupted (persisted to DB)
32
32
  if (chatSessionId) {
@@ -42,7 +42,8 @@ streamManager.on('stream:lifecycle', (event: { status: string; streamId: string;
42
42
  projectId,
43
43
  chatSessionId: chatSessionId || '',
44
44
  status: status as 'completed' | 'error' | 'cancelled',
45
- timestamp
45
+ timestamp,
46
+ reason
46
47
  });
47
48
 
48
49
  // Broadcast updated presence (status indicators for all projects)
@@ -254,8 +255,11 @@ export const streamHandler = createRouter()
254
255
  break;
255
256
  }
256
257
  } catch (err) {
258
+ // Log but do NOT unsubscribe — one bad event must not kill the
259
+ // entire stream subscription. The bridge between StreamManager
260
+ // and the WS room would be permanently broken, causing the UI
261
+ // to stop receiving stream output while the WS stays connected.
257
262
  debug.error('chat', 'Error handling stream event:', err);
258
- unsubscribe();
259
263
  }
260
264
  };
261
265
 
@@ -393,8 +397,10 @@ export const streamHandler = createRouter()
393
397
  break;
394
398
  }
395
399
  } catch (err) {
400
+ // Log but do NOT unsubscribe — same rationale as the initial
401
+ // stream handler: a transient error must not permanently break
402
+ // the EventEmitter → WS room bridge.
396
403
  debug.error('chat', 'Error handling reconnected stream event:', err);
397
- unsubscribe();
398
404
  }
399
405
  };
400
406
 
@@ -789,7 +795,8 @@ export const streamHandler = createRouter()
789
795
  projectId: t.String(),
790
796
  chatSessionId: t.String(),
791
797
  status: t.Union([t.Literal('completed'), t.Literal('error'), t.Literal('cancelled')]),
792
- timestamp: t.String()
798
+ timestamp: t.String(),
799
+ reason: t.Optional(t.String())
793
800
  }))
794
801
 
795
802
  .emit('chat:waiting-input', t.Object({
@@ -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,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
@@ -64,6 +64,19 @@
64
64
  }
65
65
  });
66
66
  });
67
+
68
+ // Force reactive tracking for assistant text streaming.
69
+ // Without an explicit $effect that reads partialText, Svelte 5's derived chain
70
+ // may not re-render the component when partialText changes on a proxied object.
71
+ // Reasoning gets this implicitly via the auto-scroll effect above.
72
+ $effect(() => {
73
+ if (roleCategory !== 'assistant') return;
74
+ if (message.type !== 'stream_event') return;
75
+ if (!('partialText' in message)) return;
76
+ // Reading partialText subscribes this effect to changes,
77
+ // which forces the component to re-evaluate its derived values
78
+ const _track = message.partialText;
79
+ });
67
80
  </script>
68
81
 
69
82
  <div class="relative overflow-hidden">
@@ -41,6 +41,11 @@
41
41
  let showDeleteFolder = $state(false);
42
42
  let folderToDelete: FileItem | null = $state(null);
43
43
  let deleteFolderConfirmName = $state('');
44
+ let showHidden = $state(false);
45
+
46
+ const filteredItems = $derived(
47
+ showHidden ? items : items.filter(item => !item.name.startsWith('.'))
48
+ );
44
49
 
45
50
  // Derived: whether directory access is restricted
46
51
  const hasRestrictions = $derived(systemSettings.allowedBasePaths && systemSettings.allowedBasePaths.length > 0);
@@ -611,6 +616,14 @@
611
616
  </div>
612
617
 
613
618
  <div class="flex items-center space-x-2">
619
+ <button
620
+ onclick={() => showHidden = !showHidden}
621
+ class="px-3 py-1.5 text-xs rounded-lg transition-colors {showHidden ? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300' : 'bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300'}"
622
+ title={showHidden ? 'Hide hidden folders' : 'Show hidden folders'}
623
+ >
624
+ <Icon name={showHidden ? 'lucide:eye' : 'lucide:eye-off'} class="inline sm:mr-1" />
625
+ <span class="hidden sm:inline">Hidden</span>
626
+ </button>
614
627
  <button
615
628
  onclick={() => showCreateFolder = true}
616
629
  class="px-3 py-1.5 text-xs rounded-lg bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 transition-colors"
@@ -651,24 +664,24 @@
651
664
  </Button>
652
665
  </div>
653
666
  </div>
654
- {:else if showLoadingSpinner && items.length === 0}
667
+ {:else if showLoadingSpinner && filteredItems.length === 0}
655
668
  <div class="flex items-center justify-center py-12">
656
669
  <div class="text-center">
657
670
  <div class="animate-spin rounded-full h-8 w-8 border-2 border-violet-500 border-t-transparent mx-auto mb-4"></div>
658
671
  <p class="text-slate-600 dark:text-slate-400">Loading directory...</p>
659
672
  </div>
660
673
  </div>
661
- {:else if items.length === 0}
674
+ {:else if filteredItems.length === 0}
662
675
  <div class="flex items-center justify-center py-12">
663
676
  <div class="text-center">
664
677
  <Icon name="lucide:folder-x" class="text-4xl text-slate-400 mx-auto mb-4" />
665
678
  <p class="text-slate-600 dark:text-slate-400">No folders found</p>
666
- <p class="text-sm text-slate-500 dark:text-slate-500 mt-2">This directory doesn't contain any subdirectories</p>
679
+ <p class="text-sm text-slate-500 dark:text-slate-500 mt-2">{items.length > 0 ? 'Toggle "Hidden" to show hidden folders' : 'This directory doesn\'t contain any subdirectories'}</p>
667
680
  </div>
668
681
  </div>
669
682
  {:else}
670
683
  <div class="space-y-2 transition-opacity duration-300 {loading ? 'opacity-75' : 'opacity-100'}">
671
- {#each items as item (item.path)}
684
+ {#each filteredItems as item (item.path)}
672
685
  <div
673
686
  class="flex items-center space-x-3 py-3 px-4 rounded-xl border transition-all duration-200 cursor-pointer {selectedPath === item.path
674
687
  ? 'bg-violet-50 dark:bg-violet-900/20 border-violet-200 dark:border-violet-700'