@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
@@ -43,16 +43,14 @@ class ChatService {
43
43
 
44
44
  static loadingTexts: string[] = [
45
45
  'thinking', 'processing', 'analyzing', 'calculating', 'computing',
46
- 'strategizing', 'learningpatterns', 'updatingweights', 'finetuning',
47
- 'adaptingmodels', 'trainingnetworks', 'evaluatingoptions', 'planningactions',
48
- 'executingplans', 'simulatingscenarios', 'predictingoutcomes', 'scanningenvironment',
49
- 'monitoringsignals', 'processinginputs', 'adjustingparameters', 'optimizing',
50
- 'generatingresponses', 'refininglogic', 'recognizingpatterns', 'synthesizinginformation',
51
- 'runninginference', 'validatingoutputs', 'modulatingresponse', 'updatingmemory',
52
- 'switchingcontext', 'resolvingconflicts', 'allocatingresources', 'prioritizingtasks',
53
- 'developingawareness', 'buildingstrategies', 'assessingscenarios', 'integratingdata',
54
- 'bootingreasoning', 'activatingmodules', 'triggeringaction', 'deployinglogic',
55
- 'maintainingstate', 'clearingcache', 'updating', 'reflecting', 'syncinglogic',
46
+ 'strategizing', 'learningpatterns', 'adaptingmodels', 'evaluatingoptions',
47
+ 'executingplans', 'simulatingscenarios', 'predictingoutcomes', 'planningactions',
48
+ 'processinginputs', 'optimizing', 'generatingresponses', 'refininglogic',
49
+ 'validatingoutputs', 'modulatingresponse', 'updatingmemory', 'recognizingpatterns',
50
+ 'switchingcontext', 'allocatingresources', 'prioritizingtasks',
51
+ 'developingawareness', 'buildingstrategies', 'assessingscenarios',
52
+ 'bootingreasoning', 'triggeringaction', 'deployinglogic', 'synthesizinginformation',
53
+ 'maintainingstate', 'updating', 'reflecting', 'syncinglogic',
56
54
  'connectingdots', 'compilingideas', 'brainstorming', 'schedulingtasks'
57
55
  ].map(text => text + '...');
58
56
 
@@ -482,11 +480,11 @@ class ChatService {
482
480
  // and global flags — cancel sets isCancelling=true to prevent presence re-enabling
483
481
  this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: true }, chatSessionId);
484
482
 
485
- // Clean up stale stream_events from the cancelled stream.
486
- // Without this, stale stream_events remain in the messages array and cause
487
- // wrong insertion positions when a new stream starts (e.g., reasoning inserted
488
- // before a stale non-reasoning stream_event instead of at the end).
489
- this.cleanupStreamEvents();
483
+ // Convert stream_events to finalized assistant messages on cancel.
484
+ // This preserves partial reasoning/text that was visible to the user.
485
+ // Empty stream_events are removed. The backend saves partial text to DB
486
+ // independently, so on refresh the DB version takes over.
487
+ this.finalizeStreamEvents();
490
488
 
491
489
  // Safety timeout: if backend events (chat:cancelled + presence update) don't
492
490
  // arrive within 10 seconds, force-clear isCancelling to prevent infinite loader.
@@ -581,15 +579,32 @@ class ChatService {
581
579
  }
582
580
  // If no reasoning stream_event found, fall through to push at end
583
581
  } else {
584
- // Remove ALL regular (non-reasoning) stream_events, not just the last one
585
- // This prevents stale stream_events from remaining when message order varies
582
+ // Replace text stream_event IN PLACE to preserve message position
583
+ // (same approach as reasoning prevents visual displacement)
586
584
  for (let i = sessionState.messages.length - 1; i >= 0; i--) {
587
585
  const msg = sessionState.messages[i] as any;
588
586
  if (msg.type === 'stream_event' && !msg.metadata?.reasoning) {
589
- sessionState.messages.splice(i, 1);
590
- break; // Only remove the most recent one
587
+ const messageFormatter = {
588
+ ...sdkMessage,
589
+ metadata: buildMetadataFromTransport(data)
590
+ };
591
+ sessionState.messages[i] = messageFormatter;
592
+
593
+ // Detect interactive tool_use blocks in the replaced message
594
+ if (sdkMessage.type === 'assistant' && sdkMessage.message?.content) {
595
+ const content = Array.isArray(sdkMessage.message.content) ? sdkMessage.message.content : [];
596
+ const hasInteractiveTool = content.some(
597
+ (item: any) => item.type === 'tool_use' && INTERACTIVE_TOOLS.has(item.name)
598
+ );
599
+ if (hasInteractiveTool) {
600
+ this.setProcessState({ isWaitingInput: true });
601
+ }
602
+ }
603
+
604
+ return; // Replaced in-place, skip push below
591
605
  }
592
606
  }
607
+ // No stream_event found, fall through to push at end
593
608
  }
594
609
  }
595
610
 
@@ -723,7 +738,7 @@ class ChatService {
723
738
  const msg = sessionState.messages[i] as any;
724
739
  if (msg.type === 'stream_event' && msg.metadata?.reasoning) {
725
740
  msg.partialText = partialText || '';
726
- break;
741
+ return;
727
742
  }
728
743
  }
729
744
  } else {
@@ -733,17 +748,42 @@ class ChatService {
733
748
  const msg = sessionState.messages[i] as any;
734
749
  if (msg.type === 'stream_event' && !msg.metadata?.reasoning) {
735
750
  msg.partialText = partialText || '';
736
- break;
751
+ return;
737
752
  }
738
753
  }
739
754
  }
755
+
756
+ // Fallback: no matching stream_event found (start event was missed).
757
+ // Create one now so text doesn't get lost.
758
+ const fallbackMessage = {
759
+ type: 'stream_event' as const,
760
+ processId: data.processId,
761
+ partialText: partialText || '',
762
+ metadata: buildMetadataFromTransport({
763
+ timestamp: data.timestamp,
764
+ ...(isReasoning && { reasoning: true }),
765
+ })
766
+ };
767
+
768
+ if (isReasoning) {
769
+ const textStreamIdx = (sessionState.messages as any[]).findIndex(
770
+ (m: any) => m.type === 'stream_event' && !m.metadata?.reasoning
771
+ );
772
+ if (textStreamIdx >= 0) {
773
+ (sessionState.messages as any[]).splice(textStreamIdx, 0, fallbackMessage);
774
+ } else {
775
+ (sessionState.messages as any[]).push(fallbackMessage);
776
+ }
777
+ } else {
778
+ (sessionState.messages as any[]).push(fallbackMessage);
779
+ }
740
780
  }
741
781
  // Note: 'end' event is not needed - streaming message will be replaced by final message in handleMessageEvent
742
782
  }
743
783
 
744
784
  /**
745
785
  * Remove all stream_event messages from the messages array.
746
- * Called on cancel and new message send to prevent stale streaming
786
+ * Called on new message send to prevent stale streaming
747
787
  * placeholders from causing wrong insertion positions.
748
788
  */
749
789
  private cleanupStreamEvents(): void {
@@ -754,6 +794,36 @@ class ChatService {
754
794
  }
755
795
  }
756
796
 
797
+ /**
798
+ * Convert stream_event messages with text to finalized assistant messages.
799
+ * Called on cancel to preserve partial reasoning/text that was visible.
800
+ * Empty stream_events (no text) are removed.
801
+ * The backend saves these to DB independently, so on refresh the DB version takes over.
802
+ */
803
+ private finalizeStreamEvents(): void {
804
+ for (let i = sessionState.messages.length - 1; i >= 0; i--) {
805
+ const msg = sessionState.messages[i] as any;
806
+ if (msg.type !== 'stream_event') continue;
807
+
808
+ if (msg.partialText) {
809
+ const isReasoning = msg.metadata?.reasoning === true;
810
+ sessionState.messages[i] = {
811
+ type: 'assistant',
812
+ message: {
813
+ role: 'assistant',
814
+ content: [{ type: 'text', text: msg.partialText }]
815
+ },
816
+ metadata: {
817
+ ...msg.metadata,
818
+ ...(isReasoning && { reasoning: true }),
819
+ }
820
+ } as any;
821
+ } else {
822
+ sessionState.messages.splice(i, 1);
823
+ }
824
+ }
825
+ }
826
+
757
827
  /**
758
828
  * Detect whether any interactive tool (e.g. AskUserQuestion) is pending in the current messages.
759
829
  * Used after browser refresh / catchup to restore the isWaitingInput state.
@@ -774,10 +844,11 @@ class ChatService {
774
844
  }
775
845
  }
776
846
 
777
- // Check if any interactive tool is unanswered
847
+ // Check if any interactive tool is unanswered (skip interrupted/cancelled messages)
778
848
  for (const msg of sessionState.messages) {
779
849
  const msgAny = msg as any;
780
850
  if (msgAny.type !== 'assistant' || !msgAny.message?.content) continue;
851
+ if (msgAny.metadata?.interrupted) continue;
781
852
  const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
782
853
  const hasPendingInteractive = content.some(
783
854
  (item: any) => item.type === 'tool_use' && INTERACTIVE_TOOLS.has(item.name) && item.id && !answeredToolIds.has(item.id)
@@ -35,13 +35,16 @@ class GlobalStreamMonitor {
35
35
 
36
36
  // Stream finished — notify on completion
37
37
  ws.on('chat:stream-finished', async (data) => {
38
- const { projectId, status, chatSessionId } = data;
38
+ const { projectId, status, chatSessionId, reason } = data;
39
39
 
40
- debug.log('notification', 'GlobalStreamMonitor: Stream finished', { projectId, status });
40
+ debug.log('notification', 'GlobalStreamMonitor: Stream finished', { projectId, status, reason });
41
41
 
42
42
  // Clean up notified IDs for this session (stream is done)
43
43
  this.clearSessionNotifications(chatSessionId);
44
44
 
45
+ // Skip notifications when stream was cancelled due to session deletion
46
+ if (reason === 'session-deleted') return;
47
+
45
48
  // Play sound notification
46
49
  try {
47
50
  await soundNotification.play();
@@ -262,66 +262,10 @@ class TerminalProjectManager {
262
262
  }
263
263
  }
264
264
 
265
- // Restore saved output for this session
266
- let baseOutput: any[] = [];
267
- let backgroundOutput: any[] = [];
268
-
269
- // First, restore base output from context (input/output sebelumnya)
270
- if (context.sessionOutputs.has(sessionId)) {
271
- const savedOutput = context.sessionOutputs.get(sessionId);
272
- if (savedOutput) {
273
- baseOutput = savedOutput.map(output => ({
274
- content: output.content,
275
- type: output.type as any,
276
- timestamp: output.timestamp
277
- }));
278
- // Restored base output lines for session
279
- }
280
- }
281
-
282
- // Second, get NEW output from server that was generated while we were away
283
- // We need to track when we saved the output to know what's new
284
- if (hasActiveStream && activeStreamInfo) {
285
- try {
286
- // Get the saved output count from context metadata
287
- // This tells us how much output we had when we switched away
288
- let savedOutputCount = 0;
289
- const savedMetadata = context.sessionOutputs.get(`${sessionId}-metadata`);
290
- if (savedMetadata && typeof savedMetadata === 'object' && 'outputCount' in savedMetadata) {
291
- savedOutputCount = (savedMetadata as any).outputCount || 0;
292
- } else {
293
- // Fallback: count actual output lines in baseOutput
294
- for (const line of baseOutput) {
295
- if (line.type === 'output' || line.type === 'error') {
296
- savedOutputCount++;
297
- }
298
- }
299
- }
300
-
301
- // Get only NEW output from server (skip what we already have)
302
- const data = await terminalService.getMissedOutput(
303
- sessionId,
304
- activeStreamInfo.streamId,
305
- savedOutputCount
306
- );
307
- if (data.success && data.output && data.output.length > 0) {
308
- // Convert server output to terminal lines
309
- backgroundOutput = data.output.map((content: string) => ({
310
- content: content,
311
- type: 'output',
312
- timestamp: new Date()
313
- }));
314
- debug.log('terminal', `Restored ${backgroundOutput.length} new output lines for session ${sessionId}`);
315
- }
316
- } catch (error) {
317
- debug.error('terminal', 'Failed to fetch missed output:', error);
318
- }
319
- }
320
-
321
- // Combine base output with background output from server
322
- // Base output contains previous input/output saved in context
323
- // Background output contains new output from server (if any)
324
- terminalSession.lines = [...baseOutput, ...backgroundOutput];
265
+ // Always start with empty lines.
266
+ // The create-session replay from headless xterm will provide the
267
+ // accurate current terminal state (respects clear, scrollback, etc.)
268
+ terminalSession.lines = [];
325
269
 
326
270
  // Restore command history from context (persisted) rather than manager (temporary)
327
271
  const savedCommandHistory = context.sessionCommandHistories.get(sessionId);
@@ -62,20 +62,6 @@ export class TerminalService {
62
62
  // Create unique stream ID for this connection
63
63
  const streamId = `${sessionId}-${Date.now()}`;
64
64
 
65
- // Get current output count to mark where new output starts
66
- let outputStartIndex = 0;
67
- if (typeof window !== 'undefined') {
68
- try {
69
- const terminalStoreModule = await import('$frontend/stores/features/terminal.svelte');
70
- const termSession = terminalStoreModule.terminalStore.getSession(sessionId);
71
- if (termSession && termSession.lines) {
72
- outputStartIndex = termSession.lines.length;
73
- }
74
- } catch {
75
- // Ignore error, use default 0
76
- }
77
- }
78
-
79
65
  // Setup WebSocket listeners for this session
80
66
  const listeners: Array<() => void> = [];
81
67
 
@@ -173,8 +159,7 @@ export class TerminalService {
173
159
  workingDirectory: session.workingDirectory,
174
160
  projectPath,
175
161
  cols: terminalSize?.cols || 80,
176
- rows: terminalSize?.rows || 24,
177
- outputStartIndex
162
+ rows: terminalSize?.rows || 24
178
163
  });
179
164
 
180
165
  debug.log('terminal', `✅ Terminal session created:`, response);
@@ -230,6 +215,17 @@ export class TerminalService {
230
215
  }
231
216
  }
232
217
 
218
+ /**
219
+ * Clear headless terminal on backend (sync with frontend clear)
220
+ */
221
+ async clearHeadlessTerminal(sessionId: string): Promise<void> {
222
+ try {
223
+ await ws.http('terminal:clear', { sessionId });
224
+ } catch {
225
+ // Silently handle - non-critical
226
+ }
227
+ }
228
+
233
229
  /**
234
230
  * Resize terminal for a specific session
235
231
  */
@@ -301,39 +297,34 @@ export class TerminalService {
301
297
  }
302
298
 
303
299
  /**
304
- * Get missed output for a session
300
+ * Get missed output for a session (serialized terminal state)
305
301
  */
306
302
  async getMissedOutput(
307
303
  sessionId: string,
308
- streamId?: string,
309
- fromIndex: number = 0
304
+ streamId?: string
310
305
  ): Promise<{
311
306
  success: boolean;
312
- output: string[];
313
- outputCount: number;
307
+ output: string;
314
308
  status: string;
315
309
  }> {
316
310
  try {
317
- const data = await ws.http('terminal:missed-output', { sessionId, streamId, fromIndex }, 5000);
311
+ const data = await ws.http('terminal:missed-output', { sessionId, streamId }, 5000);
318
312
  if (data.sessionId === sessionId) {
319
313
  return {
320
314
  success: true,
321
315
  output: data.output,
322
- outputCount: data.outputCount,
323
316
  status: data.status
324
317
  };
325
318
  }
326
319
  return {
327
320
  success: false,
328
- output: [],
329
- outputCount: 0,
321
+ output: '',
330
322
  status: 'invalid_session'
331
323
  };
332
324
  } catch {
333
325
  return {
334
326
  success: false,
335
- output: [],
336
- outputCount: 0,
327
+ output: '',
337
328
  status: 'timeout'
338
329
  };
339
330
  }
@@ -129,10 +129,18 @@ export function syncGlobalStateFromSession(sessionId: string): void {
129
129
  }
130
130
 
131
131
  /**
132
- * Remove a session's process state entry (e.g. when deleting a session).
132
+ * Remove all app-level state for a deleted session
133
+ * (process state, unread status, etc.)
133
134
  */
134
- export function clearSessionProcessState(sessionId: string): void {
135
+ export function clearSessionState(sessionId: string): void {
135
136
  delete appState.sessionStates[sessionId];
137
+
138
+ if (appState.unreadSessions.has(sessionId)) {
139
+ const next = new Map(appState.unreadSessions);
140
+ next.delete(sessionId);
141
+ appState.unreadSessions = next;
142
+ persistUnreadSessions();
143
+ }
136
144
  }
137
145
 
138
146
  // ========================================
@@ -13,7 +13,7 @@ import { buildMetadataFromTransport } from '$shared/utils/message-formatter';
13
13
  import ws, { onWsReconnect } from '$frontend/utils/ws';
14
14
  import { projectState } from './projects.svelte';
15
15
  import { setupEditModeListener, restoreEditMode } from '$frontend/stores/ui/edit-mode.svelte';
16
- import { markSessionUnread, markSessionRead, appState } from '$frontend/stores/core/app.svelte';
16
+ import { markSessionUnread, markSessionRead, clearSessionState, appState } from '$frontend/stores/core/app.svelte';
17
17
  import { debug } from '$shared/utils/logger';
18
18
 
19
19
  interface SessionState {
@@ -164,6 +164,9 @@ export function removeSession(sessionId: string) {
164
164
  sessionState.sessions.splice(index, 1);
165
165
  }
166
166
 
167
+ // Clear all app-level state for this session (unread, process state)
168
+ clearSessionState(sessionId);
169
+
167
170
  // Clear current session if it's the one being removed
168
171
  if (sessionState.currentSession?.id === sessionId) {
169
172
  sessionState.currentSession = null;
@@ -433,6 +436,12 @@ export async function initializeSessions() {
433
436
  setupCollaborativeListeners();
434
437
  setupEditModeListener();
435
438
 
439
+ // Skip loading if no project is active — both calls require WS project context
440
+ if (!projectState.currentProject) {
441
+ debug.log('session', 'No active project, skipping session load');
442
+ return;
443
+ }
444
+
436
445
  // Load sessions and restore edit mode in parallel
437
446
  // Both only need WS project context (already set by initializeProjects)
438
447
  await Promise.all([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
5
5
  "author": "Myria Labs",
6
6
  "license": "MIT",
@@ -77,17 +77,19 @@
77
77
  "dependencies": {
78
78
  "@anthropic-ai/claude-agent-sdk": "0.2.63",
79
79
  "@anthropic-ai/sdk": "0.78.0",
80
- "@opencode-ai/sdk": "1.2.15",
81
80
  "@elysiajs/cors": "^1.4.0",
82
81
  "@iconify-json/lucide": "^1.2.57",
83
82
  "@iconify-json/material-icon-theme": "^1.2.16",
84
83
  "@modelcontextprotocol/sdk": "^1.26.0",
85
84
  "@monaco-editor/loader": "^1.5.0",
85
+ "@opencode-ai/sdk": "1.2.15",
86
86
  "@xterm/addon-clipboard": "^0.2.0",
87
87
  "@xterm/addon-fit": "^0.11.0",
88
88
  "@xterm/addon-ligatures": "^0.10.0",
89
+ "@xterm/addon-serialize": "^0.14.0",
89
90
  "@xterm/addon-unicode11": "^0.9.0",
90
91
  "@xterm/addon-web-links": "^0.12.0",
92
+ "@xterm/headless": "^6.0.0",
91
93
  "@xterm/xterm": "^6.0.0",
92
94
  "bun-pty": "^0.4.2",
93
95
  "cloudflared": "^0.7.1",