@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.
- package/backend/chat/stream-manager.ts +103 -9
- package/backend/database/queries/project-queries.ts +1 -4
- package/backend/database/queries/session-queries.ts +36 -1
- package/backend/database/queries/snapshot-queries.ts +122 -0
- package/backend/database/utils/connection.ts +17 -11
- package/backend/engine/adapters/claude/stream.ts +12 -2
- package/backend/index.ts +13 -2
- package/backend/snapshot/blob-store.ts +52 -72
- package/backend/snapshot/snapshot-service.ts +24 -0
- package/backend/terminal/stream-manager.ts +41 -2
- package/backend/ws/chat/stream.ts +14 -7
- package/backend/ws/engine/claude/accounts.ts +6 -8
- package/backend/ws/projects/crud.ts +72 -7
- package/backend/ws/sessions/crud.ts +119 -2
- package/backend/ws/system/operations.ts +14 -39
- package/frontend/components/auth/SetupPage.svelte +1 -1
- package/frontend/components/chat/input/ChatInput.svelte +14 -1
- package/frontend/components/chat/message/MessageBubble.svelte +13 -0
- package/frontend/components/common/form/FolderBrowser.svelte +17 -4
- package/frontend/components/common/overlay/Dialog.svelte +17 -15
- package/frontend/components/files/FileNode.svelte +0 -15
- package/frontend/components/history/HistoryModal.svelte +94 -19
- package/frontend/components/history/HistoryView.svelte +29 -36
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
- package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
- package/frontend/components/workspace/MobileNavigator.svelte +57 -10
- package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
- package/frontend/services/chat/chat.service.ts +86 -13
- package/frontend/services/notification/global-stream-monitor.ts +5 -2
- package/frontend/stores/core/app.svelte.ts +10 -2
- package/frontend/stores/core/sessions.svelte.ts +4 -1
- 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 = '
|
|
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
|
|
38
|
-
|
|
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
|
|
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
|
-
//
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
projectQueries.
|
|
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 {
|
|
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
|
|
146
|
+
debug.log('server', 'Clearing all data...');
|
|
147
147
|
|
|
148
|
-
//
|
|
149
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
156
|
+
// Reset environment state
|
|
157
|
+
resetEnvironment();
|
|
168
158
|
|
|
169
|
-
//
|
|
170
|
-
|
|
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
|
-
|
|
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:
|
|
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 &&
|
|
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
|
|
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
|
|
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'
|