@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
@@ -93,12 +93,15 @@
93
93
  }
94
94
 
95
95
  // Delete project
96
- async function confirmDeleteProject() {
97
- if (!projectToDelete) return;
96
+ let deletingProject = $state(false);
97
+
98
+ async function confirmDeleteProject(mode: 'remove' | 'full') {
99
+ if (!projectToDelete || deletingProject) return;
98
100
  const deleteId = projectToDelete.id!;
101
+ deletingProject = true;
99
102
 
100
103
  try {
101
- await ws.http('projects:delete', { id: deleteId });
104
+ await ws.http('projects:delete', { id: deleteId, mode });
102
105
  removeProject(deleteId);
103
106
  existingProjects = existingProjects.filter(p => p.id !== deleteId);
104
107
  showDeleteDialog = false;
@@ -111,6 +114,8 @@
111
114
  message: 'Failed to delete project',
112
115
  duration: 5000
113
116
  });
117
+ } finally {
118
+ deletingProject = false;
114
119
  }
115
120
  }
116
121
 
@@ -386,13 +391,55 @@
386
391
  <Dialog
387
392
  bind:isOpen={showDeleteDialog}
388
393
  onClose={closeDeleteDialog}
389
- type="error"
390
- title="Delete Project"
391
- message='This will remove "{projectToDelete?.name}" from your project list. The actual project files on disk will not be deleted.'
392
- confirmText="Delete"
393
- cancelText="Cancel"
394
- onConfirm={confirmDeleteProject}
395
- />
394
+ type="warning"
395
+ title="Remove Project"
396
+ showCancel={false}
397
+ >
398
+ {#snippet children()}
399
+ <div class="flex items-start space-x-4">
400
+ <div class="bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-700/50 rounded-xl p-3 border">
401
+ <Icon name="lucide:triangle-alert" class="w-6 h-6 text-amber-600 dark:text-amber-400" />
402
+ </div>
403
+ <div class="flex-1">
404
+ <h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">Remove Project</h3>
405
+ <p class="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
406
+ How would you like to remove <strong>"{projectToDelete?.name}"</strong>?
407
+ </p>
408
+ </div>
409
+ </div>
410
+ <div class="flex flex-col gap-2 pt-2">
411
+ <button
412
+ onclick={() => confirmDeleteProject('remove')}
413
+ disabled={deletingProject}
414
+ class="w-full flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 text-left disabled:opacity-50"
415
+ >
416
+ <Icon name="lucide:eye-off" class="w-5 h-5 text-slate-500 shrink-0" />
417
+ <div class="flex-1 min-w-0">
418
+ <p class="text-sm font-semibold text-slate-900 dark:text-slate-100">Remove from list</p>
419
+ <p class="text-xs text-slate-500 dark:text-slate-400">Sessions will be restored when you re-add this project.</p>
420
+ </div>
421
+ </button>
422
+ <button
423
+ onclick={() => confirmDeleteProject('full')}
424
+ disabled={deletingProject}
425
+ class="w-full flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 text-left disabled:opacity-50"
426
+ >
427
+ <Icon name="lucide:trash-2" class="w-5 h-5 text-slate-500 shrink-0" />
428
+ <div class="flex-1 min-w-0">
429
+ <p class="text-sm font-semibold text-slate-900 dark:text-slate-100">Delete with all data</p>
430
+ <p class="text-xs text-slate-500 dark:text-slate-400">Delete all sessions, snapshots, and related data.</p>
431
+ </div>
432
+ </button>
433
+ <button
434
+ onclick={closeDeleteDialog}
435
+ class="w-full py-2 text-sm font-semibold text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
436
+ >
437
+ Cancel
438
+ </button>
439
+ <p class="text-xs text-slate-400 dark:text-slate-500 text-center">Your project folder on disk will not be affected.</p>
440
+ </div>
441
+ {/snippet}
442
+ </Dialog>
396
443
 
397
444
  <!-- Tunnel Modal -->
398
445
  <TunnelModal bind:isOpen={showTunnelModal} onClose={() => (showTunnelModal = false)} />
@@ -87,12 +87,15 @@
87
87
  showDeleteDialog = true;
88
88
  }
89
89
 
90
- async function confirmDeleteProject() {
91
- if (!projectToDelete) return;
90
+ let deletingProject = $state(false);
91
+
92
+ async function confirmDeleteProject(mode: 'remove' | 'full') {
93
+ if (!projectToDelete || deletingProject) return;
92
94
  const deleteId = projectToDelete.id!;
95
+ deletingProject = true;
93
96
 
94
97
  try {
95
- await ws.http('projects:delete', { id: deleteId });
98
+ await ws.http('projects:delete', { id: deleteId, mode });
96
99
  removeProject(deleteId);
97
100
  showDeleteDialog = false;
98
101
  projectToDelete = null;
@@ -104,6 +107,8 @@
104
107
  message: 'Failed to delete project',
105
108
  duration: 5000
106
109
  });
110
+ } finally {
111
+ deletingProject = false;
107
112
  }
108
113
  }
109
114
 
@@ -368,13 +373,55 @@
368
373
  <Dialog
369
374
  bind:isOpen={showDeleteDialog}
370
375
  onClose={closeDeleteDialog}
371
- type="error"
372
- title="Delete Project"
373
- message='This will remove "{projectToDelete?.name}" from your project list. The actual project files on disk will not be deleted.'
374
- confirmText="Delete"
375
- cancelText="Cancel"
376
- onConfirm={confirmDeleteProject}
377
- />
376
+ type="warning"
377
+ title="Remove Project"
378
+ showCancel={false}
379
+ >
380
+ {#snippet children()}
381
+ <div class="flex items-start space-x-4">
382
+ <div class="bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-700/50 rounded-xl p-3 border">
383
+ <Icon name="lucide:triangle-alert" class="w-6 h-6 text-amber-600 dark:text-amber-400" />
384
+ </div>
385
+ <div class="flex-1">
386
+ <h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">Remove Project</h3>
387
+ <p class="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
388
+ How would you like to remove <strong>"{projectToDelete?.name}"</strong>?
389
+ </p>
390
+ </div>
391
+ </div>
392
+ <div class="flex flex-col gap-2 pt-2">
393
+ <button
394
+ onclick={() => confirmDeleteProject('remove')}
395
+ disabled={deletingProject}
396
+ class="w-full flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 text-left disabled:opacity-50"
397
+ >
398
+ <Icon name="lucide:eye-off" class="w-5 h-5 text-slate-500 shrink-0" />
399
+ <div class="flex-1 min-w-0">
400
+ <p class="text-sm font-semibold text-slate-900 dark:text-slate-100">Remove from list</p>
401
+ <p class="text-xs text-slate-500 dark:text-slate-400">Sessions will be restored when you re-add this project.</p>
402
+ </div>
403
+ </button>
404
+ <button
405
+ onclick={() => confirmDeleteProject('full')}
406
+ disabled={deletingProject}
407
+ class="w-full flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 text-left disabled:opacity-50"
408
+ >
409
+ <Icon name="lucide:trash-2" class="w-5 h-5 text-slate-500 shrink-0" />
410
+ <div class="flex-1 min-w-0">
411
+ <p class="text-sm font-semibold text-slate-900 dark:text-slate-100">Delete with all data</p>
412
+ <p class="text-xs text-slate-500 dark:text-slate-400">Delete all sessions, snapshots, and related data.</p>
413
+ </div>
414
+ </button>
415
+ <button
416
+ onclick={closeDeleteDialog}
417
+ class="w-full py-2 text-sm font-semibold text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
418
+ >
419
+ Cancel
420
+ </button>
421
+ <p class="text-xs text-slate-400 dark:text-slate-500 text-center">Your project folder on disk will not be affected.</p>
422
+ </div>
423
+ {/snippet}
424
+ </Dialog>
378
425
 
379
426
  <!-- Tunnel Modal -->
380
427
  <TunnelModal bind:isOpen={showTunnelModal} onClose={() => (showTunnelModal = false)} />
@@ -139,14 +139,6 @@
139
139
  <div
140
140
  class="h-full w-full overflow-hidden {isMobile ? 'bg-white/90 dark:bg-slate-900/98' : 'bg-slate-50 dark:bg-slate-900/70'} text-slate-900 dark:text-slate-100 font-sans"
141
141
  >
142
- <!-- Skip link for accessibility -->
143
- <a
144
- href="#main-content"
145
- class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-violet-600 focus:text-white"
146
- >
147
- Skip to main content
148
- </a>
149
-
150
142
  {#if isMobile}
151
143
  <!-- Mobile Layout -->
152
144
  <div class="flex flex-col h-full w-full" in:fade={{ duration: 200 }}>
@@ -39,6 +39,7 @@ class ChatService {
39
39
  private lastEventSeq = new Map<string, number>(); // Sequence-based deduplication
40
40
  private cancelledProcessIds = new Set<string>(); // Track ALL cancelled streams to ignore late events
41
41
  private reconnected: boolean = false; // Whether we've reconnected to an active stream
42
+ private cancelSafetyTimer: ReturnType<typeof setTimeout> | null = null;
42
43
 
43
44
  static loadingTexts: string[] = [
44
45
  'thinking', 'processing', 'analyzing', 'calculating', 'computing',
@@ -170,6 +171,7 @@ class ChatService {
170
171
 
171
172
  this.streamCompleted = true;
172
173
  this.reconnected = false;
174
+ this.clearCancelSafetyTimer();
173
175
  this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
174
176
 
175
177
  // Mark any tool_use blocks that never got a tool_result
@@ -196,6 +198,7 @@ class ChatService {
196
198
  this.streamCompleted = true;
197
199
  this.reconnected = false;
198
200
  this.activeProcessId = null;
201
+ this.clearCancelSafetyTimer();
199
202
  // Don't clear isCancelling here — it causes a race with presence.
200
203
  // The chat:cancelled WS event arrives before broadcastPresence() updates,
201
204
  // so clearing isCancelling lets the presence $effect re-enable isLoading
@@ -479,15 +482,34 @@ class ChatService {
479
482
  // and global flags — cancel sets isCancelling=true to prevent presence re-enabling
480
483
  this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: true }, chatSessionId);
481
484
 
482
- // Clean up stale stream_events from the cancelled stream.
483
- // Without this, stale stream_events remain in the messages array and cause
484
- // wrong insertion positions when a new stream starts (e.g., reasoning inserted
485
- // before a stale non-reasoning stream_event instead of at the end).
486
- 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
+
491
+ // Safety timeout: if backend events (chat:cancelled + presence update) don't
492
+ // arrive within 10 seconds, force-clear isCancelling to prevent infinite loader.
493
+ // This catches edge cases: WS disconnect during cancel, engine.cancel() timeout,
494
+ // or race conditions between presence update and chat:cancelled event ordering.
495
+ this.clearCancelSafetyTimer();
496
+ this.cancelSafetyTimer = setTimeout(() => {
497
+ this.cancelSafetyTimer = null;
498
+ if (appState.isCancelling) {
499
+ debug.warn('chat', 'Cancel safety timeout: force-clearing isCancelling after 10s');
500
+ this.setProcessState({ isCancelling: false, isLoading: false }, chatSessionId);
501
+ }
502
+ }, 10000);
503
+ }
487
504
 
488
- // No safety timeout needed — cancel completion is confirmed via WS events:
489
- // chat:cancelled clears isLoading, then presence update clears isCancelling.
490
- // If WS disconnects, reconnection logic re-fetches presence and clears state.
505
+ /**
506
+ * Clear the cancel safety timer (called when cancel completes normally)
507
+ */
508
+ private clearCancelSafetyTimer(): void {
509
+ if (this.cancelSafetyTimer) {
510
+ clearTimeout(this.cancelSafetyTimer);
511
+ this.cancelSafetyTimer = null;
512
+ }
491
513
  }
492
514
 
493
515
  /**
@@ -559,15 +581,32 @@ class ChatService {
559
581
  }
560
582
  // If no reasoning stream_event found, fall through to push at end
561
583
  } else {
562
- // Remove ALL regular (non-reasoning) stream_events, not just the last one
563
- // 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)
564
586
  for (let i = sessionState.messages.length - 1; i >= 0; i--) {
565
587
  const msg = sessionState.messages[i] as any;
566
588
  if (msg.type === 'stream_event' && !msg.metadata?.reasoning) {
567
- sessionState.messages.splice(i, 1);
568
- 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
569
607
  }
570
608
  }
609
+ // No stream_event found, fall through to push at end
571
610
  }
572
611
  }
573
612
 
@@ -701,7 +740,7 @@ class ChatService {
701
740
  const msg = sessionState.messages[i] as any;
702
741
  if (msg.type === 'stream_event' && msg.metadata?.reasoning) {
703
742
  msg.partialText = partialText || '';
704
- break;
743
+ return;
705
744
  }
706
745
  }
707
746
  } else {
@@ -711,17 +750,42 @@ class ChatService {
711
750
  const msg = sessionState.messages[i] as any;
712
751
  if (msg.type === 'stream_event' && !msg.metadata?.reasoning) {
713
752
  msg.partialText = partialText || '';
714
- break;
753
+ return;
715
754
  }
716
755
  }
717
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
+ }
718
782
  }
719
783
  // Note: 'end' event is not needed - streaming message will be replaced by final message in handleMessageEvent
720
784
  }
721
785
 
722
786
  /**
723
787
  * Remove all stream_event messages from the messages array.
724
- * Called on cancel and new message send to prevent stale streaming
788
+ * Called on new message send to prevent stale streaming
725
789
  * placeholders from causing wrong insertion positions.
726
790
  */
727
791
  private cleanupStreamEvents(): void {
@@ -732,6 +796,36 @@ class ChatService {
732
796
  }
733
797
  }
734
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
+
735
829
  /**
736
830
  * Detect whether any interactive tool (e.g. AskUserQuestion) is pending in the current messages.
737
831
  * Used after browser refresh / catchup to restore the isWaitingInput state.
@@ -752,10 +846,11 @@ class ChatService {
752
846
  }
753
847
  }
754
848
 
755
- // Check if any interactive tool is unanswered
849
+ // Check if any interactive tool is unanswered (skip interrupted/cancelled messages)
756
850
  for (const msg of sessionState.messages) {
757
851
  const msgAny = msg as any;
758
852
  if (msgAny.type !== 'assistant' || !msgAny.message?.content) continue;
853
+ if (msgAny.metadata?.interrupted) continue;
759
854
  const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
760
855
  const hasPendingInteractive = content.some(
761
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();
@@ -54,8 +54,8 @@ async function sendNotification(
54
54
 
55
55
  try {
56
56
  const notification = new Notification(title, {
57
- icon: '/favicon.ico',
58
- badge: '/favicon.ico',
57
+ icon: '/favicon.svg',
58
+ badge: '/favicon.svg',
59
59
  ...options
60
60
  });
61
61