@myrialabs/clopen 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +61 -27
  2. package/backend/chat/stream-manager.ts +114 -16
  3. package/backend/database/queries/project-queries.ts +1 -4
  4. package/backend/database/queries/session-queries.ts +36 -1
  5. package/backend/database/queries/snapshot-queries.ts +122 -0
  6. package/backend/database/utils/connection.ts +17 -11
  7. package/backend/engine/adapters/claude/stream.ts +12 -2
  8. package/backend/engine/adapters/opencode/stream.ts +37 -19
  9. package/backend/index.ts +18 -2
  10. package/backend/mcp/servers/browser-automation/browser.ts +2 -0
  11. package/backend/preview/browser/browser-mcp-control.ts +16 -0
  12. package/backend/preview/browser/browser-navigation-tracker.ts +31 -3
  13. package/backend/preview/browser/browser-preview-service.ts +0 -34
  14. package/backend/preview/browser/browser-video-capture.ts +13 -1
  15. package/backend/preview/browser/scripts/audio-stream.ts +5 -0
  16. package/backend/preview/browser/types.ts +7 -6
  17. package/backend/snapshot/blob-store.ts +52 -72
  18. package/backend/snapshot/snapshot-service.ts +24 -0
  19. package/backend/terminal/stream-manager.ts +41 -2
  20. package/backend/ws/chat/stream.ts +14 -7
  21. package/backend/ws/engine/claude/accounts.ts +6 -8
  22. package/backend/ws/preview/browser/interact.ts +46 -50
  23. package/backend/ws/preview/browser/webcodecs.ts +24 -15
  24. package/backend/ws/projects/crud.ts +72 -7
  25. package/backend/ws/sessions/crud.ts +119 -2
  26. package/backend/ws/system/operations.ts +14 -39
  27. package/frontend/components/auth/SetupPage.svelte +1 -1
  28. package/frontend/components/chat/input/ChatInput.svelte +14 -1
  29. package/frontend/components/chat/message/MessageBubble.svelte +13 -0
  30. package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
  31. package/frontend/components/common/form/FolderBrowser.svelte +17 -4
  32. package/frontend/components/common/overlay/Dialog.svelte +17 -15
  33. package/frontend/components/files/FileNode.svelte +16 -73
  34. package/frontend/components/git/CommitForm.svelte +1 -1
  35. package/frontend/components/history/HistoryModal.svelte +94 -19
  36. package/frontend/components/history/HistoryView.svelte +29 -36
  37. package/frontend/components/preview/browser/components/Canvas.svelte +119 -42
  38. package/frontend/components/preview/browser/components/Container.svelte +18 -3
  39. package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
  40. package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
  41. package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
  42. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
  43. package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
  44. package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
  45. package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
  46. package/frontend/components/workspace/MobileNavigator.svelte +57 -10
  47. package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
  48. package/frontend/services/chat/chat.service.ts +111 -16
  49. package/frontend/services/notification/global-stream-monitor.ts +5 -2
  50. package/frontend/services/notification/push.service.ts +2 -2
  51. package/frontend/services/preview/browser/browser-webcodecs.service.ts +170 -46
  52. package/frontend/stores/core/app.svelte.ts +10 -2
  53. package/frontend/stores/core/sessions.svelte.ts +4 -1
  54. package/package.json +2 -2
@@ -64,6 +64,19 @@
64
64
  }
65
65
  });
66
66
  });
67
+
68
+ // Force reactive tracking for assistant text streaming.
69
+ // Without an explicit $effect that reads partialText, Svelte 5's derived chain
70
+ // may not re-render the component when partialText changes on a proxied object.
71
+ // Reasoning gets this implicitly via the auto-scroll effect above.
72
+ $effect(() => {
73
+ if (roleCategory !== 'assistant') return;
74
+ if (message.type !== 'stream_event') return;
75
+ if (!('partialText' in message)) return;
76
+ // Reading partialText subscribes this effect to changes,
77
+ // which forces the component to re-evaluate its derived values
78
+ const _track = message.partialText;
79
+ });
67
80
  </script>
68
81
 
69
82
  <div class="relative overflow-hidden">
@@ -48,15 +48,30 @@
48
48
  function getColorClasses(type: string) {
49
49
  switch (type) {
50
50
  case 'success':
51
- return 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900 dark:border-green-800 dark:text-green-200';
51
+ return 'bg-green-50 border-green-300 text-green-900 dark:bg-green-950 dark:border-green-700 dark:text-green-100';
52
52
  case 'error':
53
- return 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900 dark:border-red-800 dark:text-red-200';
53
+ return 'bg-red-50 border-red-300 text-red-900 dark:bg-red-950 dark:border-red-700 dark:text-red-100';
54
54
  case 'warning':
55
- return 'bg-amber-50 border-amber-200 text-amber-800 dark:bg-amber-900 dark:border-amber-800 dark:text-amber-200';
55
+ return 'bg-amber-50 border-amber-300 text-amber-900 dark:bg-amber-950 dark:border-amber-700 dark:text-amber-100';
56
56
  case 'info':
57
- return 'bg-slate-50 border-slate-200 text-slate-800 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200';
57
+ return 'bg-blue-50 border-blue-300 text-blue-900 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100';
58
58
  default:
59
- return 'bg-slate-50 border-slate-200 text-slate-800 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200';
59
+ return 'bg-slate-50 border-slate-300 text-slate-900 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100';
60
+ }
61
+ }
62
+
63
+ function getIconColorClass(type: string) {
64
+ switch (type) {
65
+ case 'success':
66
+ return 'text-green-600 dark:text-green-400';
67
+ case 'error':
68
+ return 'text-red-600 dark:text-red-400';
69
+ case 'warning':
70
+ return 'text-amber-600 dark:text-amber-400';
71
+ case 'info':
72
+ return 'text-blue-600 dark:text-blue-400';
73
+ default:
74
+ return 'text-slate-600 dark:text-slate-400';
60
75
  }
61
76
  }
62
77
  </script>
@@ -68,27 +83,27 @@
68
83
  role="alert"
69
84
  aria-live="polite"
70
85
  >
71
- <div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg p-4 {getColorClasses(notification.type)}">
86
+ <div class="border rounded-lg p-4 shadow-lg {getColorClasses(notification.type)}">
72
87
  <div class="flex items-start space-x-3">
73
- <div class="flex-shrink-0">
88
+ <div class="flex-shrink-0 {getIconColorClass(notification.type)}">
74
89
  <Icon name={getIcon(notification.type)} class="w-5 h-5" />
75
90
  </div>
76
91
 
77
92
  <div class="flex-1 min-w-0">
78
93
  <div class="flex items-center justify-between">
79
- <h4 class="font-medium text-sm">
94
+ <h4 class="font-semibold text-sm">
80
95
  {notification.title}
81
96
  </h4>
82
97
  <button
83
98
  onclick={handleDismiss}
84
- class="flex-shrink-0 ml-2 p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded transition-colors"
99
+ class="flex flex-shrink-0 ml-2 p-1 rounded opacity-60 hover:opacity-100 transition-opacity"
85
100
  aria-label="Dismiss notification"
86
101
  >
87
102
  <Icon name="lucide:x" class="w-4 h-4" />
88
103
  </button>
89
104
  </div>
90
105
 
91
- <p class="text-sm opacity-90 mt-1">
106
+ <p class="text-sm opacity-80 mt-1">
92
107
  {notification.message}
93
108
  </p>
94
109
 
@@ -100,7 +115,7 @@
100
115
  action.action();
101
116
  handleDismiss();
102
117
  }}
103
- class="text-xs font-medium px-3 py-1 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-md transition-colors"
118
+ class="text-xs font-medium px-3 py-1 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20 rounded-md transition-colors"
104
119
  >
105
120
  {action.label}
106
121
  </button>
@@ -41,6 +41,11 @@
41
41
  let showDeleteFolder = $state(false);
42
42
  let folderToDelete: FileItem | null = $state(null);
43
43
  let deleteFolderConfirmName = $state('');
44
+ let showHidden = $state(false);
45
+
46
+ const filteredItems = $derived(
47
+ showHidden ? items : items.filter(item => !item.name.startsWith('.'))
48
+ );
44
49
 
45
50
  // Derived: whether directory access is restricted
46
51
  const hasRestrictions = $derived(systemSettings.allowedBasePaths && systemSettings.allowedBasePaths.length > 0);
@@ -611,6 +616,14 @@
611
616
  </div>
612
617
 
613
618
  <div class="flex items-center space-x-2">
619
+ <button
620
+ onclick={() => showHidden = !showHidden}
621
+ class="px-3 py-1.5 text-xs rounded-lg transition-colors {showHidden ? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300' : 'bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300'}"
622
+ title={showHidden ? 'Hide hidden folders' : 'Show hidden folders'}
623
+ >
624
+ <Icon name={showHidden ? 'lucide:eye' : 'lucide:eye-off'} class="inline sm:mr-1" />
625
+ <span class="hidden sm:inline">Hidden</span>
626
+ </button>
614
627
  <button
615
628
  onclick={() => showCreateFolder = true}
616
629
  class="px-3 py-1.5 text-xs rounded-lg bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 transition-colors"
@@ -651,24 +664,24 @@
651
664
  </Button>
652
665
  </div>
653
666
  </div>
654
- {:else if showLoadingSpinner && items.length === 0}
667
+ {:else if showLoadingSpinner && filteredItems.length === 0}
655
668
  <div class="flex items-center justify-center py-12">
656
669
  <div class="text-center">
657
670
  <div class="animate-spin rounded-full h-8 w-8 border-2 border-violet-500 border-t-transparent mx-auto mb-4"></div>
658
671
  <p class="text-slate-600 dark:text-slate-400">Loading directory...</p>
659
672
  </div>
660
673
  </div>
661
- {:else if items.length === 0}
674
+ {:else if filteredItems.length === 0}
662
675
  <div class="flex items-center justify-center py-12">
663
676
  <div class="text-center">
664
677
  <Icon name="lucide:folder-x" class="text-4xl text-slate-400 mx-auto mb-4" />
665
678
  <p class="text-slate-600 dark:text-slate-400">No folders found</p>
666
- <p class="text-sm text-slate-500 dark:text-slate-500 mt-2">This directory doesn't contain any subdirectories</p>
679
+ <p class="text-sm text-slate-500 dark:text-slate-500 mt-2">{items.length > 0 ? 'Toggle "Hidden" to show hidden folders' : 'This directory doesn\'t contain any subdirectories'}</p>
667
680
  </div>
668
681
  </div>
669
682
  {:else}
670
683
  <div class="space-y-2 transition-opacity duration-300 {loading ? 'opacity-75' : 'opacity-100'}">
671
- {#each items as item (item.path)}
684
+ {#each filteredItems as item (item.path)}
672
685
  <div
673
686
  class="flex items-center space-x-3 py-3 px-4 rounded-xl border transition-all duration-200 cursor-pointer {selectedPath === item.path
674
687
  ? 'bg-violet-50 dark:bg-violet-900/20 border-violet-200 dark:border-violet-700'
@@ -226,24 +226,26 @@
226
226
  </div>
227
227
  </div>
228
228
  {/if}
229
-
230
- <div class="flex justify-end gap-3 pt-2">
231
- {#if showCancel}
229
+
230
+ {#if !children}
231
+ <div class="flex justify-end gap-3 pt-2">
232
+ {#if showCancel}
233
+ <button
234
+ onclick={handleCancel}
235
+ class="px-6 py-2.5 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 font-semibold"
236
+ >
237
+ {cancelText}
238
+ </button>
239
+ {/if}
232
240
  <button
233
- onclick={handleCancel}
234
- class="px-6 py-2.5 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 font-semibold"
241
+ onclick={handleConfirm}
242
+ disabled={confirmDisabled}
243
+ class="px-6 py-2.5 {colors.button} rounded-lg transition-all duration-200 font-semibold focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-current"
235
244
  >
236
- {cancelText}
245
+ {confirmText}
237
246
  </button>
238
- {/if}
239
- <button
240
- onclick={handleConfirm}
241
- disabled={confirmDisabled}
242
- class="px-6 py-2.5 {colors.button} rounded-lg transition-all duration-200 font-semibold focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-current"
243
- >
244
- {confirmText}
245
- </button>
246
- </div>
247
+ </div>
248
+ {/if}
247
249
  </div>
248
250
  </div>
249
251
  {/if}
@@ -62,50 +62,29 @@
62
62
 
63
63
  let nodeElement: HTMLDivElement;
64
64
  let menuButtonElement: HTMLButtonElement;
65
- let showAbove = $state(false);
66
-
67
- // Context menu positioning
68
- let menuOpenedViaContextMenu = $state(false);
69
- let contextMenuX = $state(0);
70
- let contextMenuY = $state(0);
71
-
72
- function checkMenuPosition() {
73
- if (!menuButtonElement) return;
74
-
75
- const rect = menuButtonElement.getBoundingClientRect();
76
- const dockContainer = nodeElement?.closest('.overflow-auto');
77
-
78
- if (!dockContainer) {
79
- // Fallback ke viewport jika tidak ada container
80
- const viewportHeight = window.innerHeight;
81
- const menuHeight = 100;
82
- showAbove = rect.bottom + menuHeight > viewportHeight && rect.top > menuHeight;
83
- return;
84
- }
85
-
86
- const dockRect = dockContainer.getBoundingClientRect();
87
- const menuHeight = 100; // Estimasi tinggi menu dropdown
88
-
89
- // Hitung ruang yang tersedia di bawah dan di atas dalam dock container
90
- const spaceBelow = dockRect.bottom - rect.bottom;
91
- const spaceAbove = rect.top - dockRect.top;
92
-
93
- // Jika tidak cukup ruang di bawah untuk menu dan ada cukup ruang di atas, tampilkan di atas
94
- showAbove = spaceBelow < menuHeight && spaceAbove > menuHeight;
65
+ let menuStyle = $state('');
66
+
67
+ function computeMenuStyle(x: number, y: number, alignRight: boolean): string {
68
+ const menuHeight = 200;
69
+ const isAbove = y + menuHeight > window.innerHeight && y > menuHeight;
70
+ const verticalStyle = isAbove
71
+ ? `bottom: ${window.innerHeight - y}px;`
72
+ : `top: ${y}px;`;
73
+ const horizontalStyle = alignRight ? `right: ${x}px;` : `left: ${x}px;`;
74
+ return `${horizontalStyle} ${verticalStyle}`;
95
75
  }
96
76
 
97
77
  function toggleMenu(event: Event) {
98
78
  event.stopPropagation();
99
79
  if (!isMenuOpen) {
100
- checkMenuPosition();
101
- menuOpenedViaContextMenu = false; // Opened via button click
80
+ const rect = menuButtonElement.getBoundingClientRect();
81
+ menuStyle = computeMenuStyle(window.innerWidth - rect.right, rect.bottom, true);
102
82
  }
103
83
  onMenuToggle?.(file.path);
104
84
  }
105
85
 
106
86
  function closeMenu() {
107
- onMenuToggle?.(file.path); // Toggle to close
108
- menuOpenedViaContextMenu = false;
87
+ onMenuToggle?.(file.path);
109
88
  }
110
89
 
111
90
  function getDisplayIcon(fileName: string, isDirectory: boolean): IconName {
@@ -127,45 +106,16 @@
127
106
  function handleContextMenu(event: MouseEvent) {
128
107
  event.preventDefault();
129
108
  if (!isMenuOpen) {
130
- // Save mouse position for context menu positioning
131
- contextMenuX = event.clientX;
132
- contextMenuY = event.clientY;
133
- menuOpenedViaContextMenu = true;
134
- // Check position based on mouse Y relative to dock container
135
- checkContextMenuPosition(event.clientY);
109
+ menuStyle = computeMenuStyle(event.clientX, event.clientY, false);
136
110
  }
137
111
  onMenuToggle?.(file.path);
138
112
  }
139
113
 
140
- function checkContextMenuPosition(mouseY: number) {
141
- const dockContainer = nodeElement?.closest('.overflow-auto');
142
- const menuHeight = 100;
143
-
144
- if (!dockContainer) {
145
- const viewportHeight = window.innerHeight;
146
- showAbove = mouseY + menuHeight > viewportHeight && mouseY > menuHeight;
147
- return;
148
- }
149
-
150
- const dockRect = dockContainer.getBoundingClientRect();
151
- const spaceBelow = dockRect.bottom - mouseY;
152
- const spaceAbove = mouseY - dockRect.top;
153
- showAbove = spaceBelow < menuHeight && spaceAbove > menuHeight;
154
- }
155
-
156
114
  function handleAction(action: string, event: Event) {
157
115
  event.stopPropagation();
158
116
  onAction?.(action, file);
159
117
  }
160
118
 
161
- function formatFileSize(bytes: number): string {
162
- if (bytes === 0) return '0 B';
163
- const k = 1024;
164
- const sizes = ['B', 'KB', 'MB', 'GB'];
165
- const i = Math.floor(Math.log(bytes) / Math.log(k));
166
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
167
- }
168
-
169
119
  // Close menu when clicking outside
170
120
  onMount(() => {
171
121
  function handleClickOutside(event: MouseEvent) {
@@ -226,13 +176,6 @@
226
176
  {/if}
227
177
  </span>
228
178
 
229
- <!-- File metadata -->
230
- {#if file.type === 'file'}
231
- <span class="flex-shrink-0 text-xs text-slate-400 dark:text-slate-500 lg:group-hover:hidden">
232
- {formatFileSize(file.size || 0)}
233
- </span>
234
- {/if}
235
-
236
179
  <!-- Actions menu (always visible, triggered by click) -->
237
180
  <div class="flex-shrink-0">
238
181
  <div class="relative">
@@ -250,8 +193,8 @@
250
193
  <div
251
194
  role="menu"
252
195
  tabindex="-1"
253
- class="{menuOpenedViaContextMenu ? 'fixed' : 'absolute right-0'} {showAbove && !menuOpenedViaContextMenu ? 'bottom-full -mb-5' : !menuOpenedViaContextMenu ? 'top-full -mt-5' : ''} bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg py-1 w-44 max-h-80 overflow-y-auto z-50 shadow-lg"
254
- style={menuOpenedViaContextMenu ? `left: ${contextMenuX}px; ${showAbove ? `bottom: ${window.innerHeight - contextMenuY}px;` : `top: ${contextMenuY}px;`}` : ''}
196
+ class="fixed bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg py-1 w-44 max-h-80 overflow-y-auto z-50 shadow-lg"
197
+ style={menuStyle}
255
198
  onclick={(e) => e.stopPropagation()}
256
199
  >
257
200
  <!-- New File & New Folder (hanya untuk directory) -->
@@ -77,7 +77,7 @@
77
77
 
78
78
  <div class="px-2 py-2">
79
79
  <div class="flex flex-col gap-1.5">
80
- <div class="relative">
80
+ <div class="flex relative">
81
81
  <textarea
82
82
  bind:this={textareaEl}
83
83
  bind:value={commitMessage}
@@ -275,7 +275,7 @@
275
275
  }
276
276
  }
277
277
 
278
- // Delete session state
278
+ // Delete single session state
279
279
  let showDeleteDialog = $state(false);
280
280
  let sessionToDelete = $state<ChatSession | null>(null);
281
281
 
@@ -299,7 +299,7 @@
299
299
  addNotification({
300
300
  type: 'success',
301
301
  title: 'Session Deleted',
302
- message: 'Chat session has been deleted',
302
+ message: 'Chat session and related data have been deleted',
303
303
  duration: 3000
304
304
  });
305
305
 
@@ -321,6 +321,54 @@
321
321
  sessionToDelete = null;
322
322
  }
323
323
 
324
+ // Delete all sessions state
325
+ let showDeleteAllDialog = $state(false);
326
+ let deletingAll = $state(false);
327
+
328
+ async function confirmDeleteAllSessions() {
329
+ deletingAll = true;
330
+ try {
331
+ const result = await ws.http('sessions:delete-all', {});
332
+
333
+ // Remove all project sessions from local state
334
+ const projectId = projectState.currentProject?.id;
335
+ if (projectId) {
336
+ const toRemove = sessionState.sessions
337
+ .filter(s => s.project_id === projectId)
338
+ .map(s => s.id);
339
+ for (const id of toRemove) {
340
+ removeSession(id);
341
+ }
342
+ }
343
+
344
+ // Clear cache
345
+ sessionDataCache = {};
346
+
347
+ addNotification({
348
+ type: 'success',
349
+ title: 'All Sessions Deleted',
350
+ message: `${result.deletedCount} sessions and related data have been deleted`,
351
+ duration: 3000
352
+ });
353
+
354
+ showDeleteAllDialog = false;
355
+ } catch (error) {
356
+ debug.error('session', 'Failed to delete all sessions:', error);
357
+ addNotification({
358
+ type: 'error',
359
+ title: 'Error',
360
+ message: 'Failed to delete all sessions',
361
+ duration: 5000
362
+ });
363
+ } finally {
364
+ deletingAll = false;
365
+ }
366
+ }
367
+
368
+ function closeDeleteAllDialog() {
369
+ showDeleteAllDialog = false;
370
+ }
371
+
324
372
  function closeModal() {
325
373
  searchQuery = '';
326
374
  onClose();
@@ -331,21 +379,34 @@
331
379
  {#snippet header()}
332
380
  <div class="flex items-center justify-between px-4 py-3 md:px-6 md:py-4">
333
381
  <h2 class="text-base md:text-lg font-bold text-slate-900 dark:text-slate-100">Sessions</h2>
334
- <button
335
- type="button"
336
- class="p-1.5 md:p-2 rounded-lg text-slate-500 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-violet-500/10 transition-colors"
337
- onclick={closeModal}
338
- aria-label="Close modal"
339
- >
340
- <svg class="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
341
- <path
342
- stroke-linecap="round"
343
- stroke-linejoin="round"
344
- stroke-width="2"
345
- d="M6 18L18 6M6 6l12 12"
346
- />
347
- </svg>
348
- </button>
382
+ <div class="flex items-center gap-2">
383
+ {#if filteredSessions.length > 0}
384
+ <button
385
+ type="button"
386
+ class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-500/10 transition-colors"
387
+ onclick={() => (showDeleteAllDialog = true)}
388
+ aria-label="Delete all sessions"
389
+ >
390
+ <Icon name="lucide:trash-2" class="w-3.5 h-3.5" />
391
+ <span class="hidden sm:inline">Delete All</span>
392
+ </button>
393
+ {/if}
394
+ <button
395
+ type="button"
396
+ class="p-1.5 md:p-2 rounded-lg text-slate-500 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-violet-500/10 transition-colors"
397
+ onclick={closeModal}
398
+ aria-label="Close modal"
399
+ >
400
+ <svg class="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
401
+ <path
402
+ stroke-linecap="round"
403
+ stroke-linejoin="round"
404
+ stroke-width="2"
405
+ d="M6 18L18 6M6 6l12 12"
406
+ />
407
+ </svg>
408
+ </button>
409
+ </div>
349
410
  </div>
350
411
  {/snippet}
351
412
 
@@ -514,14 +575,28 @@
514
575
  {/snippet}
515
576
  </Modal>
516
577
 
517
- <!-- Delete Confirmation Dialog -->
578
+ <!-- Delete Single Session Confirmation Dialog -->
518
579
  <Dialog
519
580
  bind:isOpen={showDeleteDialog}
520
581
  onClose={closeDeleteDialog}
521
582
  type="error"
522
583
  title="Delete Session"
523
- message="Are you sure you want to delete this session? All messages will be permanently removed."
584
+ message={sessionToDelete && isSessionStreaming(sessionToDelete.id)
585
+ ? 'This session is currently running. Deleting it will stop the active chat and permanently remove all messages, snapshots, and related data.'
586
+ : 'Are you sure you want to delete this session? All messages, snapshots, and related data will be permanently removed.'}
524
587
  confirmText="Delete"
525
588
  cancelText="Cancel"
526
589
  onConfirm={confirmDeleteSession}
527
590
  />
591
+
592
+ <!-- Delete All Sessions Confirmation Dialog -->
593
+ <Dialog
594
+ bind:isOpen={showDeleteAllDialog}
595
+ onClose={closeDeleteAllDialog}
596
+ type="error"
597
+ title="Delete All Sessions"
598
+ message={`Are you sure you want to delete all ${filteredSessions.length} sessions in this project? All messages, snapshots, and related data will be permanently removed. This only affects the current project.`}
599
+ confirmText={deletingAll ? 'Deleting...' : 'Delete All'}
600
+ cancelText="Cancel"
601
+ onConfirm={confirmDeleteAllSessions}
602
+ />
@@ -14,8 +14,17 @@
14
14
  import { showConfirm } from '$frontend/stores/ui/dialog.svelte';
15
15
  import Modal from '$frontend/components/common/overlay/Modal.svelte';
16
16
  import TimelineModal from '../checkpoint/TimelineModal.svelte';
17
+ import { presenceState } from '$frontend/stores/core/presence.svelte';
17
18
  import { debug } from '$shared/utils/logger';
18
19
 
20
+ // Check if a session has an active stream
21
+ function isSessionStreaming(chatSessionId: string): boolean {
22
+ for (const status of presenceState.statuses.values()) {
23
+ if (status.streams?.some(s => s.status === 'active' && s.chatSessionId === chatSessionId)) return true;
24
+ }
25
+ return false;
26
+ }
27
+
19
28
  // Use real session data from session store
20
29
  const sessions = $derived(sessionState.sessions);
21
30
 
@@ -303,27 +312,25 @@
303
312
  async function deleteSession(session: ChatSession) {
304
313
  const sessionData = await getSessionData(session.id);
305
314
  const title = sessionData.title;
306
-
315
+ const streaming = isSessionStreaming(session.id);
316
+
307
317
  const confirmed = await showConfirm({
308
318
  title: 'Delete Session',
309
- message: `Are you sure you want to delete session "${title}"? This action cannot be undone.`,
319
+ message: streaming
320
+ ? `This session "${title}" is currently running. Deleting it will stop the active chat and permanently remove all messages, snapshots, and related data.`
321
+ : `Are you sure you want to delete session "${title}"? All messages, snapshots, and related data will be permanently removed.`,
310
322
  type: 'error',
311
323
  confirmText: 'Delete',
312
324
  cancelText: 'Cancel'
313
325
  });
314
-
326
+
315
327
  if (confirmed) {
316
328
  try {
317
- // Delete from database via WebSocket
318
329
  await ws.http('sessions:delete', { id: session.id });
319
- // Remove from local state
320
330
  removeSession(session.id);
321
- // Clear cache
322
331
  delete sessionDataCache[session.id];
323
- // User already knows session was deleted from UI update
324
332
  } catch (error) {
325
333
  addNotification({
326
-
327
334
  type: 'error',
328
335
  title: 'Error',
329
336
  message: 'Failed to delete session',
@@ -332,11 +339,11 @@
332
339
  }
333
340
  }
334
341
  }
335
-
342
+
336
343
  async function clearHistory() {
337
344
  const confirmed = await showConfirm({
338
345
  title: 'Clear All Session History',
339
- message: 'Are you sure you want to clear all session history? This will delete all sessions. This action cannot be undone.',
346
+ message: 'Are you sure you want to clear all session history? All messages, snapshots, and related data will be permanently removed. This only affects the current project.',
340
347
  type: 'error',
341
348
  confirmText: 'Clear All',
342
349
  cancelText: 'Cancel'
@@ -349,34 +356,20 @@
349
356
  }
350
357
 
351
358
  try {
352
- // Delete all sessions from database
353
- const deletePromises = sessions.map(async (session) => {
354
- try {
355
- await ws.http('sessions:delete', { id: session.id });
356
- // Remove from local state
357
- removeSession(session.id);
358
- // Clear cache
359
- delete sessionDataCache[session.id];
360
- return { success: true };
361
- } catch (error) {
362
- debug.error('session', `Error deleting session ${session.id}:`, error);
363
- return { success: false, error: 'Failed to delete session' };
364
- }
365
- });
366
-
367
- // Wait for all deletions to complete
368
- const results = await Promise.all(deletePromises);
359
+ const result = await ws.http('sessions:delete-all', {});
369
360
 
370
- // Check if any deletions failed
371
- const failed = results.filter(r => !r.success);
372
- if (failed.length > 0) {
373
- addNotification({
374
- type: 'warning',
375
- title: 'Partial Deletion',
376
- message: `Failed to delete ${failed.length} session(s)`,
377
- duration: 5000
378
- });
361
+ // Remove all sessions from local state
362
+ const toRemove = [...sessions].map(s => s.id);
363
+ for (const id of toRemove) {
364
+ removeSession(id);
379
365
  }
366
+
367
+ addNotification({
368
+ type: 'success',
369
+ title: 'All Sessions Deleted',
370
+ message: `${result.deletedCount} sessions and related data have been deleted`,
371
+ duration: 3000
372
+ });
380
373
  } catch (error) {
381
374
  debug.error('session', 'Error clearing history:', error);
382
375
  addNotification({