@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.
- package/README.md +61 -27
- package/backend/chat/stream-manager.ts +114 -16
- 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/engine/adapters/opencode/stream.ts +37 -19
- package/backend/index.ts +18 -2
- package/backend/mcp/servers/browser-automation/browser.ts +2 -0
- package/backend/preview/browser/browser-mcp-control.ts +16 -0
- package/backend/preview/browser/browser-navigation-tracker.ts +31 -3
- package/backend/preview/browser/browser-preview-service.ts +0 -34
- package/backend/preview/browser/browser-video-capture.ts +13 -1
- package/backend/preview/browser/scripts/audio-stream.ts +5 -0
- package/backend/preview/browser/types.ts +7 -6
- 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/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +24 -15
- 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/feedback/NotificationToast.svelte +26 -11
- package/frontend/components/common/form/FolderBrowser.svelte +17 -4
- package/frontend/components/common/overlay/Dialog.svelte +17 -15
- package/frontend/components/files/FileNode.svelte +16 -73
- package/frontend/components/git/CommitForm.svelte +1 -1
- package/frontend/components/history/HistoryModal.svelte +94 -19
- package/frontend/components/history/HistoryView.svelte +29 -36
- package/frontend/components/preview/browser/components/Canvas.svelte +119 -42
- package/frontend/components/preview/browser/components/Container.svelte +18 -3
- package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
- 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 +111 -16
- package/frontend/services/notification/global-stream-monitor.ts +5 -2
- package/frontend/services/notification/push.service.ts +2 -2
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +170 -46
- package/frontend/stores/core/app.svelte.ts +10 -2
- package/frontend/stores/core/sessions.svelte.ts +4 -1
- 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
|
|
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,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
|
-
//
|
|
112
|
-
|
|
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
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|