@myrialabs/clopen 0.2.11 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/backend/chat/stream-manager.ts +103 -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 +12 -2
  7. package/backend/index.ts +13 -2
  8. package/backend/snapshot/blob-store.ts +52 -72
  9. package/backend/snapshot/snapshot-service.ts +24 -0
  10. package/backend/terminal/stream-manager.ts +41 -2
  11. package/backend/ws/chat/stream.ts +14 -7
  12. package/backend/ws/engine/claude/accounts.ts +6 -8
  13. package/backend/ws/projects/crud.ts +72 -7
  14. package/backend/ws/sessions/crud.ts +119 -2
  15. package/backend/ws/system/operations.ts +14 -39
  16. package/frontend/components/auth/SetupPage.svelte +1 -1
  17. package/frontend/components/chat/input/ChatInput.svelte +14 -1
  18. package/frontend/components/chat/message/MessageBubble.svelte +13 -0
  19. package/frontend/components/common/form/FolderBrowser.svelte +17 -4
  20. package/frontend/components/common/overlay/Dialog.svelte +17 -15
  21. package/frontend/components/files/FileNode.svelte +0 -15
  22. package/frontend/components/history/HistoryModal.svelte +94 -19
  23. package/frontend/components/history/HistoryView.svelte +29 -36
  24. package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
  25. package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
  26. package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
  27. package/frontend/components/workspace/MobileNavigator.svelte +57 -10
  28. package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
  29. package/frontend/services/chat/chat.service.ts +86 -13
  30. package/frontend/services/notification/global-stream-monitor.ts +5 -2
  31. package/frontend/stores/core/app.svelte.ts +10 -2
  32. package/frontend/stores/core/sessions.svelte.ts +4 -1
  33. package/package.json +1 -1
@@ -482,11 +482,11 @@ class ChatService {
482
482
  // and global flags — cancel sets isCancelling=true to prevent presence re-enabling
483
483
  this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: true }, chatSessionId);
484
484
 
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();
485
+ // Convert stream_events to finalized assistant messages on cancel.
486
+ // This preserves partial reasoning/text that was visible to the user.
487
+ // Empty stream_events are removed. The backend saves partial text to DB
488
+ // independently, so on refresh the DB version takes over.
489
+ this.finalizeStreamEvents();
490
490
 
491
491
  // Safety timeout: if backend events (chat:cancelled + presence update) don't
492
492
  // arrive within 10 seconds, force-clear isCancelling to prevent infinite loader.
@@ -581,15 +581,32 @@ class ChatService {
581
581
  }
582
582
  // If no reasoning stream_event found, fall through to push at end
583
583
  } 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
584
+ // Replace text stream_event IN PLACE to preserve message position
585
+ // (same approach as reasoning prevents visual displacement)
586
586
  for (let i = sessionState.messages.length - 1; i >= 0; i--) {
587
587
  const msg = sessionState.messages[i] as any;
588
588
  if (msg.type === 'stream_event' && !msg.metadata?.reasoning) {
589
- sessionState.messages.splice(i, 1);
590
- break; // Only remove the most recent one
589
+ const messageFormatter = {
590
+ ...sdkMessage,
591
+ metadata: buildMetadataFromTransport(data)
592
+ };
593
+ sessionState.messages[i] = messageFormatter;
594
+
595
+ // Detect interactive tool_use blocks in the replaced message
596
+ if (sdkMessage.type === 'assistant' && sdkMessage.message?.content) {
597
+ const content = Array.isArray(sdkMessage.message.content) ? sdkMessage.message.content : [];
598
+ const hasInteractiveTool = content.some(
599
+ (item: any) => item.type === 'tool_use' && INTERACTIVE_TOOLS.has(item.name)
600
+ );
601
+ if (hasInteractiveTool) {
602
+ this.setProcessState({ isWaitingInput: true });
603
+ }
604
+ }
605
+
606
+ return; // Replaced in-place, skip push below
591
607
  }
592
608
  }
609
+ // No stream_event found, fall through to push at end
593
610
  }
594
611
  }
595
612
 
@@ -723,7 +740,7 @@ class ChatService {
723
740
  const msg = sessionState.messages[i] as any;
724
741
  if (msg.type === 'stream_event' && msg.metadata?.reasoning) {
725
742
  msg.partialText = partialText || '';
726
- break;
743
+ return;
727
744
  }
728
745
  }
729
746
  } else {
@@ -733,17 +750,42 @@ class ChatService {
733
750
  const msg = sessionState.messages[i] as any;
734
751
  if (msg.type === 'stream_event' && !msg.metadata?.reasoning) {
735
752
  msg.partialText = partialText || '';
736
- break;
753
+ return;
737
754
  }
738
755
  }
739
756
  }
757
+
758
+ // Fallback: no matching stream_event found (start event was missed).
759
+ // Create one now so text doesn't get lost.
760
+ const fallbackMessage = {
761
+ type: 'stream_event' as const,
762
+ processId: data.processId,
763
+ partialText: partialText || '',
764
+ metadata: buildMetadataFromTransport({
765
+ timestamp: data.timestamp,
766
+ ...(isReasoning && { reasoning: true }),
767
+ })
768
+ };
769
+
770
+ if (isReasoning) {
771
+ const textStreamIdx = (sessionState.messages as any[]).findIndex(
772
+ (m: any) => m.type === 'stream_event' && !m.metadata?.reasoning
773
+ );
774
+ if (textStreamIdx >= 0) {
775
+ (sessionState.messages as any[]).splice(textStreamIdx, 0, fallbackMessage);
776
+ } else {
777
+ (sessionState.messages as any[]).push(fallbackMessage);
778
+ }
779
+ } else {
780
+ (sessionState.messages as any[]).push(fallbackMessage);
781
+ }
740
782
  }
741
783
  // Note: 'end' event is not needed - streaming message will be replaced by final message in handleMessageEvent
742
784
  }
743
785
 
744
786
  /**
745
787
  * Remove all stream_event messages from the messages array.
746
- * Called on cancel and new message send to prevent stale streaming
788
+ * Called on new message send to prevent stale streaming
747
789
  * placeholders from causing wrong insertion positions.
748
790
  */
749
791
  private cleanupStreamEvents(): void {
@@ -754,6 +796,36 @@ class ChatService {
754
796
  }
755
797
  }
756
798
 
799
+ /**
800
+ * Convert stream_event messages with text to finalized assistant messages.
801
+ * Called on cancel to preserve partial reasoning/text that was visible.
802
+ * Empty stream_events (no text) are removed.
803
+ * The backend saves these to DB independently, so on refresh the DB version takes over.
804
+ */
805
+ private finalizeStreamEvents(): void {
806
+ for (let i = sessionState.messages.length - 1; i >= 0; i--) {
807
+ const msg = sessionState.messages[i] as any;
808
+ if (msg.type !== 'stream_event') continue;
809
+
810
+ if (msg.partialText) {
811
+ const isReasoning = msg.metadata?.reasoning === true;
812
+ sessionState.messages[i] = {
813
+ type: 'assistant',
814
+ message: {
815
+ role: 'assistant',
816
+ content: [{ type: 'text', text: msg.partialText }]
817
+ },
818
+ metadata: {
819
+ ...msg.metadata,
820
+ ...(isReasoning && { reasoning: true }),
821
+ }
822
+ } as any;
823
+ } else {
824
+ sessionState.messages.splice(i, 1);
825
+ }
826
+ }
827
+ }
828
+
757
829
  /**
758
830
  * Detect whether any interactive tool (e.g. AskUserQuestion) is pending in the current messages.
759
831
  * Used after browser refresh / catchup to restore the isWaitingInput state.
@@ -774,10 +846,11 @@ class ChatService {
774
846
  }
775
847
  }
776
848
 
777
- // Check if any interactive tool is unanswered
849
+ // Check if any interactive tool is unanswered (skip interrupted/cancelled messages)
778
850
  for (const msg of sessionState.messages) {
779
851
  const msgAny = msg as any;
780
852
  if (msgAny.type !== 'assistant' || !msgAny.message?.content) continue;
853
+ if (msgAny.metadata?.interrupted) continue;
781
854
  const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
782
855
  const hasPendingInteractive = content.some(
783
856
  (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();
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
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",