@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.
Files changed (45) hide show
  1. package/backend/chat/stream-manager.ts +106 -9
  2. package/backend/database/queries/project-queries.ts +1 -4
  3. package/backend/database/queries/session-queries.ts +36 -1
  4. package/backend/database/queries/snapshot-queries.ts +122 -0
  5. package/backend/database/utils/connection.ts +17 -11
  6. package/backend/engine/adapters/claude/stream.ts +14 -3
  7. package/backend/engine/types.ts +9 -0
  8. package/backend/index.ts +13 -2
  9. package/backend/mcp/config.ts +32 -6
  10. package/backend/snapshot/blob-store.ts +52 -72
  11. package/backend/snapshot/snapshot-service.ts +24 -0
  12. package/backend/terminal/stream-manager.ts +121 -131
  13. package/backend/ws/chat/stream.ts +14 -7
  14. package/backend/ws/engine/claude/accounts.ts +6 -8
  15. package/backend/ws/projects/crud.ts +72 -7
  16. package/backend/ws/sessions/crud.ts +119 -2
  17. package/backend/ws/system/operations.ts +14 -39
  18. package/backend/ws/terminal/persistence.ts +19 -33
  19. package/backend/ws/terminal/session.ts +37 -19
  20. package/bun.lock +6 -0
  21. package/frontend/components/auth/SetupPage.svelte +1 -1
  22. package/frontend/components/chat/input/ChatInput.svelte +22 -1
  23. package/frontend/components/chat/input/composables/use-animations.svelte.ts +127 -111
  24. package/frontend/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -1
  25. package/frontend/components/chat/message/MessageBubble.svelte +13 -0
  26. package/frontend/components/chat/widgets/FloatingTodoList.svelte +2 -2
  27. package/frontend/components/common/form/FolderBrowser.svelte +17 -4
  28. package/frontend/components/common/overlay/Dialog.svelte +17 -15
  29. package/frontend/components/files/FileNode.svelte +0 -15
  30. package/frontend/components/git/ChangesSection.svelte +104 -13
  31. package/frontend/components/history/HistoryModal.svelte +94 -19
  32. package/frontend/components/history/HistoryView.svelte +29 -36
  33. package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
  34. package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
  35. package/frontend/components/terminal/Terminal.svelte +5 -1
  36. package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
  37. package/frontend/components/workspace/MobileNavigator.svelte +57 -10
  38. package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
  39. package/frontend/services/chat/chat.service.ts +94 -23
  40. package/frontend/services/notification/global-stream-monitor.ts +5 -2
  41. package/frontend/services/terminal/project.service.ts +4 -60
  42. package/frontend/services/terminal/terminal.service.ts +18 -27
  43. package/frontend/stores/core/app.svelte.ts +10 -2
  44. package/frontend/stores/core/sessions.svelte.ts +10 -1
  45. 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 user association, cleanup if orphaned)
90
+ // Delete project (remove from list or full delete with sessions)
85
91
  .http('projects:delete', {
86
92
  data: t.Object({
87
- id: t.String({ minLength: 1 })
93
+ id: t.String({ minLength: 1 }),
94
+ mode: t.Optional(t.Union([t.Literal('remove'), t.Literal('full')]))
88
95
  }),
89
96
  response: t.Object({
90
97
  id: t.String(),
@@ -97,15 +104,73 @@ export const crudHandler = createRouter()
97
104
  throw new Error('Project not found');
98
105
  }
99
106
 
107
+ const mode = data.mode ?? 'remove';
108
+
109
+ // Clean up terminal 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
- // If no more users are associated, delete the project entirely
104
- const remainingUsers = projectQueries.getUserCountForProject(data.id);
105
- if (remainingUsers === 0) {
106
- projectQueries.delete(data.id);
163
+ // In full mode, delete the project record if no users remain
164
+ // In remove mode, keep the project record so sessions can be restored
165
+ if (mode === 'full') {
166
+ const remainingUsers = projectQueries.getUserCountForProject(data.id);
167
+ if (remainingUsers === 0) {
168
+ projectQueries.deleteProject(data.id);
169
+ }
107
170
  }
108
171
 
172
+ broadcastPresence().catch(() => {});
173
+
109
174
  return {
110
175
  id: data.id,
111
176
  deleted: true
@@ -13,8 +13,12 @@
13
13
  import { t } from 'elysia';
14
14
  import { createRouter } from '$shared/utils/ws-server';
15
15
  import type { EngineType } from '$shared/types/engine';
16
- import { sessionQueries, messageQueries, projectQueries } from '../../database/queries';
16
+ import { sessionQueries, messageQueries, projectQueries, snapshotQueries } from '../../database/queries';
17
17
  import { ws } from '$backend/utils/ws';
18
+ import { streamManager } from '../../chat/stream-manager';
19
+ import { snapshotService } from '../../snapshot/snapshot-service';
20
+ import { blobStore } from '../../snapshot/blob-store';
21
+ import { broadcastPresence } from '../projects/status';
18
22
  import { debug } from '$shared/utils/logger';
19
23
 
20
24
  export const crudHandler = createRouter()
@@ -293,7 +297,7 @@ export const crudHandler = createRouter()
293
297
  };
294
298
  })
295
299
 
296
- // Delete session
300
+ // Delete session (with full related data cleanup)
297
301
  .http('sessions:delete', {
298
302
  data: t.Object({
299
303
  id: t.String({ minLength: 1 })
@@ -309,8 +313,40 @@ export const crudHandler = createRouter()
309
313
  }
310
314
 
311
315
  const projectId = session.project_id;
316
+
317
+ // Cancel and clean up any active/completed streams for this session
318
+ await streamManager.cleanupSessionStreams(data.id);
319
+
320
+ // Collect ALL blob hashes before deleting anything:
321
+ // 1. In-memory baseline hashes (all project files hashed at session start)
322
+ const baselineHashes = snapshotService.getSessionBaselineHashes(data.id);
323
+ // 2. DB snapshot delta hashes (including soft-deleted snapshots)
324
+ const allSnapshots = snapshotQueries.getAllBySessionId(data.id);
325
+ const deltaHashes = snapshotQueries.collectBlobHashes(allSnapshots);
326
+ // Combine both sources
327
+ const hashesToCheck = new Set([...baselineHashes, ...deltaHashes]);
328
+
329
+ debug.log('session', `Session ${data.id}: ${baselineHashes.size} baseline hashes, ${deltaHashes.size} delta hashes, ${hashesToCheck.size} total unique`);
330
+
331
+ // Clear in-memory snapshot baseline
332
+ snapshotService.clearSessionBaseline(data.id);
333
+
334
+ // Delete session and all related DB data (messages, snapshots, branches, relationships, unread)
312
335
  sessionQueries.delete(data.id);
313
336
 
337
+ // Clean up orphaned blobs — protect hashes still used by other active sessions
338
+ if (hashesToCheck.size > 0) {
339
+ const stillReferencedByDB = snapshotQueries.getAllReferencedBlobHashes();
340
+ const stillReferencedByMemory = snapshotService.getAllBaselineHashes();
341
+ const orphanHashes = [...hashesToCheck].filter(
342
+ h => !stillReferencedByDB.has(h) && !stillReferencedByMemory.has(h)
343
+ );
344
+ if (orphanHashes.length > 0) {
345
+ const deleted = await blobStore.deleteBlobs(orphanHashes);
346
+ debug.log('session', `Cleaned up ${deleted}/${orphanHashes.length} orphaned blobs`);
347
+ }
348
+ }
349
+
314
350
  // Broadcast to all project members so other users see the deletion
315
351
  debug.log('session', `Broadcasting session deleted: ${data.id} in project: ${projectId}`);
316
352
  ws.emit.project(projectId, 'sessions:session-deleted', {
@@ -318,11 +354,92 @@ export const crudHandler = createRouter()
318
354
  projectId
319
355
  });
320
356
 
357
+ // Broadcast updated presence so status indicators reflect the deletion
358
+ broadcastPresence().catch(() => {});
359
+
321
360
  return {
322
361
  message: 'Session deleted successfully'
323
362
  };
324
363
  })
325
364
 
365
+ // Delete all sessions for the current project (with full cleanup)
366
+ .http('sessions:delete-all', {
367
+ data: t.Object({}),
368
+ response: t.Object({
369
+ message: t.String(),
370
+ deletedCount: t.Number()
371
+ })
372
+ }, async ({ conn }) => {
373
+ const projectId = ws.getProjectId(conn);
374
+
375
+ // Get all sessions for this project to clean up streams
376
+ const sessions = sessionQueries.getByProjectId(projectId);
377
+ if (sessions.length === 0) {
378
+ return { message: 'No sessions to delete', deletedCount: 0 };
379
+ }
380
+
381
+ // Cancel and clean up active streams for all sessions
382
+ await Promise.all(
383
+ sessions.map(s => streamManager.cleanupSessionStreams(s.id).catch(() => {}))
384
+ );
385
+
386
+ // Collect ALL blob hashes before deleting anything:
387
+ // 1. In-memory baseline hashes from all sessions
388
+ const baselineHashes = new Set<string>();
389
+ for (const s of sessions) {
390
+ for (const h of snapshotService.getSessionBaselineHashes(s.id)) {
391
+ baselineHashes.add(h);
392
+ }
393
+ }
394
+ // 2. DB snapshot delta hashes (including soft-deleted)
395
+ const allSnapshots = snapshotQueries.getAllByProjectId(projectId);
396
+ const deltaHashes = snapshotQueries.collectBlobHashes(allSnapshots);
397
+ // Combine both sources
398
+ const hashesToCheck = new Set([...baselineHashes, ...deltaHashes]);
399
+
400
+ debug.log('session', `Project ${projectId}: ${baselineHashes.size} baseline hashes, ${deltaHashes.size} delta hashes, ${hashesToCheck.size} total unique`);
401
+
402
+ // Clear in-memory snapshot baselines for all sessions
403
+ for (const s of sessions) {
404
+ snapshotService.clearSessionBaseline(s.id);
405
+ }
406
+
407
+ // Delete all sessions and related DB data (messages, snapshots, branches, relationships, unread)
408
+ const deletedIds = sessionQueries.deleteAllByProjectId(projectId);
409
+
410
+ // Clean up orphaned blobs using mark-and-sweep GC:
411
+ // Scan ALL blobs on disk, keep those still referenced by remaining DB snapshots
412
+ // or by in-memory baselines of other active sessions (other projects)
413
+ const allBlobsOnDisk = await blobStore.scanAllBlobHashes();
414
+ const stillReferencedByDB = snapshotQueries.getAllReferencedBlobHashes();
415
+ const stillReferencedByMemory = snapshotService.getAllBaselineHashes();
416
+
417
+ const blobsToDelete = [...allBlobsOnDisk].filter(
418
+ h => !stillReferencedByDB.has(h) && !stillReferencedByMemory.has(h)
419
+ );
420
+
421
+ if (blobsToDelete.length > 0) {
422
+ const deleted = await blobStore.deleteBlobs(blobsToDelete);
423
+ debug.log('session', `Cleaned up ${deleted}/${blobsToDelete.length} orphaned blobs (full GC after bulk delete)`);
424
+ }
425
+
426
+ // Broadcast deletion for each session so all connected users update their state
427
+ for (const sessionId of deletedIds) {
428
+ ws.emit.project(projectId, 'sessions:session-deleted', {
429
+ sessionId,
430
+ projectId
431
+ });
432
+ }
433
+
434
+ broadcastPresence().catch(() => {});
435
+
436
+ debug.log('session', `Deleted all ${deletedIds.length} sessions in project: ${projectId}`);
437
+ return {
438
+ message: `Deleted ${deletedIds.length} sessions`,
439
+ deletedCount: deletedIds.length
440
+ };
441
+ })
442
+
326
443
  // Persist user's current session choice (for refresh restore)
327
444
  .on('sessions:set-current', {
328
445
  data: t.Object({
@@ -12,7 +12,7 @@ import { join } from 'node:path';
12
12
  import { readFileSync } from 'node:fs';
13
13
  import fs from 'node:fs/promises';
14
14
  import { createRouter } from '$shared/utils/ws-server';
15
- import { initializeDatabase, getDatabase } from '../../database';
15
+ import { closeDatabase, initializeDatabase } from '../../database';
16
16
  import { debug } from '$shared/utils/logger';
17
17
  import { ws } from '$backend/utils/ws';
18
18
  import { getClopenDir } from '$backend/utils/index';
@@ -143,51 +143,26 @@ export const operationsHandler = createRouter()
143
143
  tablesCount: t.Number()
144
144
  })
145
145
  }, async () => {
146
- debug.log('server', 'Clearing all database data...');
146
+ debug.log('server', 'Clearing all data...');
147
147
 
148
- // Initialize database first to ensure it exists
149
- await initializeDatabase();
150
-
151
- // Get database connection
152
- const db = getDatabase();
153
-
154
- // Get all table names
155
- const tables = db.prepare(`
156
- SELECT name FROM sqlite_master
157
- WHERE type='table'
158
- AND name NOT LIKE 'sqlite_%'
159
- `).all() as { name: string }[];
148
+ // Close database connection
149
+ closeDatabase();
160
150
 
161
- // Delete all data from each table
162
- for (const table of tables) {
163
- db.prepare(`DELETE FROM ${table.name}`).run();
164
- debug.log('server', `Cleared table: ${table.name}`);
165
- }
151
+ // Delete entire clopen directory
152
+ const clopenDir = getClopenDir();
153
+ await fs.rm(clopenDir, { recursive: true, force: true });
154
+ debug.log('server', 'Deleted clopen directory:', clopenDir);
166
155
 
167
- debug.log('server', 'Database cleared successfully');
156
+ // Reset environment state
157
+ resetEnvironment();
168
158
 
169
- // Delete snapshots directory
170
- const clopenDir = getClopenDir();
171
- const snapshotsDir = join(clopenDir, 'snapshots');
172
- try {
173
- await fs.rm(snapshotsDir, { recursive: true, force: true });
174
- debug.log('server', 'Snapshots directory cleared');
175
- } catch (err) {
176
- debug.warn('server', 'Failed to clear snapshots directory:', err);
177
- }
159
+ // Reinitialize database from scratch (creates dir, db, migrations, seeders)
160
+ await initializeDatabase();
178
161
 
179
- // Delete Claude config directory and reset environment state
180
- const claudeDir = join(clopenDir, 'claude');
181
- try {
182
- await fs.rm(claudeDir, { recursive: true, force: true });
183
- resetEnvironment();
184
- debug.log('server', 'Claude config directory cleared');
185
- } catch (err) {
186
- debug.warn('server', 'Failed to clear Claude config directory:', err);
187
- }
162
+ debug.log('server', 'All data cleared successfully');
188
163
 
189
164
  return {
190
165
  cleared: true,
191
- tablesCount: tables.length
166
+ tablesCount: 0
192
167
  };
193
168
  });
@@ -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.Array(t.String()),
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, fromIndex = 0 } = data;
50
+ const { sessionId, streamId } = data;
54
51
 
55
- // Try to get output from stream manager (memory or cache)
56
- let output: string[] = [];
52
+ // Get serialized terminal state from headless xterm
53
+ let output = '';
57
54
 
58
55
  if (streamId) {
59
- // If streamId is provided, get output from that specific stream
60
- output = terminalStreamManager.getOutput(streamId, fromIndex);
56
+ output = terminalStreamManager.getSerializedOutput(streamId);
61
57
  } else {
62
- // Otherwise try to load cached output for the session
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, fromIndex = 0 } = data;
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
- // Broadcast missed output (frontend filters by sessionId for one-time replay)
106
- const existingOutput = terminalStreamManager.getOutput(streamId, fromIndex);
107
-
108
- if (existingOutput.length > 0) {
109
- for (const output of existingOutput) {
110
- ws.emit.project(projectId, 'terminal:output', {
111
- sessionId,
112
- content: output,
113
- timestamp: new Date().toISOString()
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
- outputStartIndex
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 historical output for reconnection (e.g., after browser refresh)
183
- // The stream preserves output from the old stream when reconnecting to the same PTY.
184
- // Replay from outputStartIndex so frontend receives all output it doesn't have yet.
185
- const historicalOutput = terminalStreamManager.getOutput(registeredStreamId, outputStartIndex);
186
- if (historicalOutput.length > 0) {
187
- debug.log('terminal', `📜 Replaying ${historicalOutput.length} historical output entries for session ${sessionId}`);
188
- for (const output of historicalOutput) {
189
- ws.emit.project(projectId, 'terminal:output', {
190
- sessionId,
191
- content: output,
192
- projectId,
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