@myrialabs/clopen 0.2.11 → 0.2.13
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 +106 -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 +14 -3
- package/backend/engine/types.ts +9 -0
- package/backend/index.ts +13 -2
- package/backend/mcp/config.ts +32 -6
- package/backend/snapshot/blob-store.ts +52 -72
- package/backend/snapshot/snapshot-service.ts +24 -0
- package/backend/terminal/stream-manager.ts +121 -131
- 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/backend/ws/terminal/persistence.ts +19 -33
- package/backend/ws/terminal/session.ts +37 -19
- package/bun.lock +6 -0
- package/frontend/components/auth/SetupPage.svelte +1 -1
- package/frontend/components/chat/input/ChatInput.svelte +22 -1
- package/frontend/components/chat/input/composables/use-animations.svelte.ts +127 -111
- package/frontend/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -1
- package/frontend/components/chat/message/MessageBubble.svelte +13 -0
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +2 -2
- 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/git/ChangesSection.svelte +104 -13
- 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/terminal/Terminal.svelte +5 -1
- 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 +94 -23
- package/frontend/services/notification/global-stream-monitor.ts +5 -2
- package/frontend/services/terminal/project.service.ts +4 -60
- package/frontend/services/terminal/terminal.service.ts +18 -27
- package/frontend/stores/core/app.svelte.ts +10 -2
- package/frontend/stores/core/sessions.svelte.ts +10 -1
- package/package.json +4 -2
|
@@ -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 streams for this project
|
|
110
|
+
const cleanedStreams = terminalStreamManager.cleanupProjectStreams(data.id);
|
|
111
|
+
debug.log('project', `Cleaned up ${cleanedStreams} terminal streams 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
|
});
|
|
@@ -33,49 +33,38 @@ export const persistenceHandler = createRouter()
|
|
|
33
33
|
return status;
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
-
// Get missed output
|
|
36
|
+
// Get missed output (serialized terminal state)
|
|
37
37
|
.http('terminal:missed-output', {
|
|
38
38
|
data: t.Object({
|
|
39
39
|
sessionId: t.String(),
|
|
40
|
-
streamId: t.Optional(t.String())
|
|
41
|
-
fromIndex: t.Optional(t.Number())
|
|
40
|
+
streamId: t.Optional(t.String())
|
|
42
41
|
}),
|
|
43
42
|
response: t.Object({
|
|
44
43
|
sessionId: t.String(),
|
|
45
44
|
streamId: t.Union([t.String(), t.Null()]),
|
|
46
|
-
output: t.
|
|
47
|
-
outputCount: t.Number(),
|
|
45
|
+
output: t.String(),
|
|
48
46
|
status: t.String(),
|
|
49
|
-
fromIndex: t.Number(),
|
|
50
47
|
timestamp: t.String()
|
|
51
48
|
})
|
|
52
49
|
}, async ({ data }) => {
|
|
53
|
-
const { sessionId, streamId
|
|
50
|
+
const { sessionId, streamId } = data;
|
|
54
51
|
|
|
55
|
-
//
|
|
56
|
-
let output
|
|
52
|
+
// Get serialized terminal state from headless xterm
|
|
53
|
+
let output = '';
|
|
57
54
|
|
|
58
55
|
if (streamId) {
|
|
59
|
-
|
|
60
|
-
output = terminalStreamManager.getOutput(streamId, fromIndex);
|
|
56
|
+
output = terminalStreamManager.getSerializedOutput(streamId);
|
|
61
57
|
} else {
|
|
62
|
-
|
|
63
|
-
const cachedOutput = terminalStreamManager.loadCachedOutput(sessionId);
|
|
64
|
-
if (cachedOutput) {
|
|
65
|
-
output = cachedOutput.slice(fromIndex);
|
|
66
|
-
}
|
|
58
|
+
output = terminalStreamManager.getSerializedOutputBySession(sessionId);
|
|
67
59
|
}
|
|
68
60
|
|
|
69
|
-
// Get stream status if available
|
|
70
61
|
const streamStatus = streamId ? terminalStreamManager.getStreamStatus(streamId) : null;
|
|
71
62
|
|
|
72
63
|
return {
|
|
73
64
|
sessionId,
|
|
74
65
|
streamId: streamId || null,
|
|
75
66
|
output,
|
|
76
|
-
outputCount: output.length,
|
|
77
67
|
status: streamStatus?.status || 'unknown',
|
|
78
|
-
fromIndex,
|
|
79
68
|
timestamp: new Date().toISOString()
|
|
80
69
|
};
|
|
81
70
|
})
|
|
@@ -84,11 +73,10 @@ export const persistenceHandler = createRouter()
|
|
|
84
73
|
.on('terminal:reconnect', {
|
|
85
74
|
data: t.Object({
|
|
86
75
|
streamId: t.String(),
|
|
87
|
-
sessionId: t.String()
|
|
88
|
-
fromIndex: t.Optional(t.Number())
|
|
76
|
+
sessionId: t.String()
|
|
89
77
|
})
|
|
90
78
|
}, async ({ data, conn }) => {
|
|
91
|
-
const { streamId, sessionId
|
|
79
|
+
const { streamId, sessionId } = data;
|
|
92
80
|
const projectId = ws.getProjectId(conn);
|
|
93
81
|
|
|
94
82
|
const stream = terminalStreamManager.getStream(streamId);
|
|
@@ -102,17 +90,15 @@ export const persistenceHandler = createRouter()
|
|
|
102
90
|
}
|
|
103
91
|
|
|
104
92
|
try {
|
|
105
|
-
//
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
});
|
|
115
|
-
}
|
|
93
|
+
// Send serialized terminal state (frontend writes it to xterm to restore)
|
|
94
|
+
const serializedOutput = terminalStreamManager.getSerializedOutput(streamId);
|
|
95
|
+
|
|
96
|
+
if (serializedOutput) {
|
|
97
|
+
ws.emit.project(projectId, 'terminal:output', {
|
|
98
|
+
sessionId,
|
|
99
|
+
content: serializedOutput,
|
|
100
|
+
timestamp: new Date().toISOString()
|
|
101
|
+
});
|
|
116
102
|
}
|
|
117
103
|
|
|
118
104
|
if (stream.status === 'active') {
|
|
@@ -30,8 +30,7 @@ export const sessionHandler = createRouter()
|
|
|
30
30
|
workingDirectory: t.Optional(t.String()),
|
|
31
31
|
projectPath: t.Optional(t.String()),
|
|
32
32
|
cols: t.Optional(t.Number()),
|
|
33
|
-
rows: t.Optional(t.Number())
|
|
34
|
-
outputStartIndex: t.Optional(t.Number())
|
|
33
|
+
rows: t.Optional(t.Number())
|
|
35
34
|
}),
|
|
36
35
|
response: t.Object({
|
|
37
36
|
sessionId: t.String(),
|
|
@@ -48,8 +47,7 @@ export const sessionHandler = createRouter()
|
|
|
48
47
|
workingDirectory,
|
|
49
48
|
projectPath,
|
|
50
49
|
cols = 80,
|
|
51
|
-
rows = 24
|
|
52
|
-
outputStartIndex = 0
|
|
50
|
+
rows = 24
|
|
53
51
|
} = data;
|
|
54
52
|
|
|
55
53
|
const projectId = ws.getProjectId(conn);
|
|
@@ -120,7 +118,7 @@ export const sessionHandler = createRouter()
|
|
|
120
118
|
projectPath || '',
|
|
121
119
|
projectId || '',
|
|
122
120
|
streamId,
|
|
123
|
-
|
|
121
|
+
{ cols, rows }
|
|
124
122
|
);
|
|
125
123
|
|
|
126
124
|
// Broadcast initial ready event (frontend filters by sessionId)
|
|
@@ -179,20 +177,17 @@ export const sessionHandler = createRouter()
|
|
|
179
177
|
|
|
180
178
|
debug.log('terminal', `✅ Added fresh listeners to PTY session ${sessionId}`);
|
|
181
179
|
|
|
182
|
-
// Replay
|
|
183
|
-
// The
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
timestamp: new Date().toISOString()
|
|
194
|
-
});
|
|
195
|
-
}
|
|
180
|
+
// Replay serialized terminal state for reconnection (e.g., after browser refresh)
|
|
181
|
+
// The headless xterm preserves full terminal state including clear/scrollback
|
|
182
|
+
const serializedOutput = terminalStreamManager.getSerializedOutput(registeredStreamId);
|
|
183
|
+
if (serializedOutput) {
|
|
184
|
+
debug.log('terminal', `📜 Replaying serialized terminal state for session ${sessionId}`);
|
|
185
|
+
ws.emit.project(projectId, 'terminal:output', {
|
|
186
|
+
sessionId,
|
|
187
|
+
content: serializedOutput,
|
|
188
|
+
projectId,
|
|
189
|
+
timestamp: new Date().toISOString()
|
|
190
|
+
});
|
|
196
191
|
}
|
|
197
192
|
|
|
198
193
|
// Broadcast terminal tab created to all project users
|
|
@@ -216,6 +211,20 @@ export const sessionHandler = createRouter()
|
|
|
216
211
|
};
|
|
217
212
|
})
|
|
218
213
|
|
|
214
|
+
// Clear headless terminal buffer (sync with frontend clear)
|
|
215
|
+
.http('terminal:clear', {
|
|
216
|
+
data: t.Object({
|
|
217
|
+
sessionId: t.String()
|
|
218
|
+
}),
|
|
219
|
+
response: t.Object({
|
|
220
|
+
sessionId: t.String()
|
|
221
|
+
})
|
|
222
|
+
}, async ({ data }) => {
|
|
223
|
+
const { sessionId } = data;
|
|
224
|
+
terminalStreamManager.clearHeadlessTerminal(sessionId);
|
|
225
|
+
return { sessionId };
|
|
226
|
+
})
|
|
227
|
+
|
|
219
228
|
// Resize terminal viewport
|
|
220
229
|
.http('terminal:resize', {
|
|
221
230
|
data: t.Object({
|
|
@@ -239,6 +248,9 @@ export const sessionHandler = createRouter()
|
|
|
239
248
|
throw new Error('No active PTY session found');
|
|
240
249
|
}
|
|
241
250
|
|
|
251
|
+
// Keep headless terminal in sync with PTY dimensions
|
|
252
|
+
terminalStreamManager.resizeHeadlessTerminal(sessionId, cols, rows);
|
|
253
|
+
|
|
242
254
|
return { sessionId, cols, rows };
|
|
243
255
|
})
|
|
244
256
|
|
|
@@ -309,6 +321,12 @@ export const sessionHandler = createRouter()
|
|
|
309
321
|
|
|
310
322
|
debug.log('terminal', `💀 [kill-session] Successfully killed PTY session: ${sessionId} (PID: ${pid})`);
|
|
311
323
|
|
|
324
|
+
// Clean up stream and headless terminal
|
|
325
|
+
const stream = terminalStreamManager.getStreamBySession(sessionId);
|
|
326
|
+
if (stream) {
|
|
327
|
+
terminalStreamManager.removeStream(stream.streamId);
|
|
328
|
+
}
|
|
329
|
+
|
|
312
330
|
// Broadcast terminal tab closed to all project users
|
|
313
331
|
const projectId = ws.getProjectId(conn);
|
|
314
332
|
ws.emit.project(projectId, 'terminal:tab-closed', {
|
package/bun.lock
CHANGED
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
"@xterm/addon-clipboard": "^0.2.0",
|
|
17
17
|
"@xterm/addon-fit": "^0.11.0",
|
|
18
18
|
"@xterm/addon-ligatures": "^0.10.0",
|
|
19
|
+
"@xterm/addon-serialize": "^0.14.0",
|
|
19
20
|
"@xterm/addon-unicode11": "^0.9.0",
|
|
20
21
|
"@xterm/addon-web-links": "^0.12.0",
|
|
22
|
+
"@xterm/headless": "^6.0.0",
|
|
21
23
|
"@xterm/xterm": "^6.0.0",
|
|
22
24
|
"bun-pty": "^0.4.2",
|
|
23
25
|
"cloudflared": "^0.7.1",
|
|
@@ -348,10 +350,14 @@
|
|
|
348
350
|
|
|
349
351
|
"@xterm/addon-ligatures": ["@xterm/addon-ligatures@0.10.0", "", { "dependencies": { "font-finder": "^1.1.0", "font-ligatures": "^1.4.1" } }, "sha512-/Few8ZSHMib7sGjRJoc5l7bCtEB9XJfkNofvPpOcWADxKaUl8og8P172j67OoACSNJAXqeCLIuvj8WFCBkcTxg=="],
|
|
350
352
|
|
|
353
|
+
"@xterm/addon-serialize": ["@xterm/addon-serialize@0.14.0", "", {}, "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA=="],
|
|
354
|
+
|
|
351
355
|
"@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="],
|
|
352
356
|
|
|
353
357
|
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
|
|
354
358
|
|
|
359
|
+
"@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="],
|
|
360
|
+
|
|
355
361
|
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
|
|
356
362
|
|
|
357
363
|
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
|
@@ -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
|
|
@@ -178,10 +178,31 @@
|
|
|
178
178
|
}
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
+
// Resize textarea when placeholder text changes (typewriter animation) while empty
|
|
182
|
+
$effect(() => {
|
|
183
|
+
chatPlaceholder; // track placeholder changes
|
|
184
|
+
if (!messageText || !messageText.trim()) {
|
|
185
|
+
adjustTextareaHeight();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
181
189
|
// Sync appState.isLoading from presence data (single source of truth for all users)
|
|
182
190
|
// Also fetch partial text and reconnect to stream for late-joining users / refresh
|
|
183
191
|
let lastCatchupProjectId: string | undefined;
|
|
184
192
|
let lastPresenceProjectId: string | undefined;
|
|
193
|
+
|
|
194
|
+
// Reset catchup tracking on WS reconnect so catchupActiveStream re-runs.
|
|
195
|
+
// When WS briefly disconnects, the server-side cleanup removes the stream
|
|
196
|
+
// EventEmitter subscription. Without resetting, catchup won't fire again
|
|
197
|
+
// (guarded by lastCatchupProjectId) and the stream subscription is never
|
|
198
|
+
// re-established — causing stream output to silently stop in the UI.
|
|
199
|
+
onWsReconnect(() => {
|
|
200
|
+
if (lastCatchupProjectId) {
|
|
201
|
+
debug.log('chat', 'WS reconnected — resetting stream catchup tracking');
|
|
202
|
+
lastCatchupProjectId = undefined;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
185
206
|
$effect(() => {
|
|
186
207
|
const projectId = projectState.currentProject?.id;
|
|
187
208
|
const sessionId = sessionState.currentSession?.id; // Reactive dep: retry catchup when session loads
|