From 155c27aac92f315e263c77aa25c2cb67778da097 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 10 Aug 2026 23:09:48 -0700 Subject: [PATCH] fix(notification): replace repeated desktop status toasts - Add optional replaceId to showDesktopNotification; a repeated id closes the prior toast instead of stacking a new one - Character dictionary auto-sync now shares one replaceId across checking/generating/importing/ready phases - Startup OSD sequencer passes its lane id as replaceId so each startup lane keeps a single live notification --- src/core/utils/notification.ts | 18 ++++- ...dictionary-auto-sync-notifications.test.ts | 66 +++++++++++++++++++ ...cter-dictionary-auto-sync-notifications.ts | 16 ++++- src/main/runtime/startup-osd-sequencer.ts | 8 ++- 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/core/utils/notification.ts b/src/core/utils/notification.ts index e92b88fb..9a94673a 100644 --- a/src/core/utils/notification.ts +++ b/src/core/utils/notification.ts @@ -52,9 +52,15 @@ function resolveRuntimeDefaultNotificationIconPath(): string | null { }); } +/** + * Live notifications keyed by `replaceId`. Electron exposes no native "replace this notification" + * flag, so a repeated status closes its predecessor instead of stacking a fresh toast per update. + */ +const notificationsByReplaceId = new Map(); + export function showDesktopNotification( title: string, - options: { body?: string; icon?: string }, + options: { body?: string; icon?: string; replaceId?: string }, ): void { const notificationOptions: { title: string; @@ -98,5 +104,15 @@ export function showDesktopNotification( } const notification = new Notification(notificationOptions); + const replaceId = options.replaceId?.trim(); + if (replaceId) { + notificationsByReplaceId.get(replaceId)?.close(); + notificationsByReplaceId.set(replaceId, notification); + notification.once('close', () => { + if (notificationsByReplaceId.get(replaceId) === notification) { + notificationsByReplaceId.delete(replaceId); + } + }); + } notification.show(); } diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts index 8f97ecdf..61fb7629 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts @@ -214,3 +214,69 @@ test('auto sync notifications let startup sequencer own osd-system desktop deliv assert.deepEqual(calls, ['osd:importing', 'desktop:SubMiner:importing']); }); + +test('auto sync desktop notifications reuse one replace id across every phase', () => { + const replaceIds: Array = []; + const deps = { + getNotificationType: () => 'system' as const, + showOsd: () => undefined, + showDesktopNotification: (_title: string, options: { body?: string; replaceId?: string }) => { + replaceIds.push(options.replaceId); + }, + }; + + for (const phase of ['checking', 'generating', 'importing', 'ready'] as const) { + notifyCharacterDictionaryAutoSyncStatus(makeEvent(phase, phase), deps); + } + + assert.deepEqual(replaceIds, [ + 'character-dictionary-auto-sync', + 'character-dictionary-auto-sync', + 'character-dictionary-auto-sync', + 'character-dictionary-auto-sync', + ]); +}); + +test('overlay-unavailable desktop fallback shares the same replace id', () => { + const replaceIds: Array = []; + + notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), { + getNotificationType: () => 'overlay', + showOsd: () => undefined, + showDesktopNotification: (_title, options) => { + replaceIds.push(options.replaceId); + }, + }); + + assert.deepEqual(replaceIds, ['character-dictionary-auto-sync']); +}); + +test('startup lanes keep one desktop notification per lane', () => { + const calls: Array<{ body?: string; replaceId?: string }> = []; + const sequencer = createStartupOsdSequencer({ + getNotificationType: () => 'system', + showOsd: () => undefined, + showDesktopNotification: (_title, options) => { + calls.push(options); + }, + }); + + sequencer.markTokenizationReady(); + notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating one'), { + getNotificationType: () => 'osd', + showOsd: () => undefined, + showDesktopNotification: () => undefined, + startupOsdSequencer: sequencer, + }); + notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating two'), { + getNotificationType: () => 'osd', + showOsd: () => undefined, + showDesktopNotification: () => undefined, + startupOsdSequencer: sequencer, + }); + + assert.deepEqual( + calls.map((call) => call.replaceId), + ['startup-status', 'startup-status'], + ); +}); diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.ts index 5496746b..9c3215b4 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.ts @@ -9,7 +9,7 @@ export interface CharacterDictionaryAutoSyncNotificationDeps { getNotificationType: () => NotificationType | undefined; showOsd: (message: string) => boolean | void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; - showDesktopNotification: (title: string, options: { body?: string }) => void; + showDesktopNotification: (title: string, options: { body?: string; replaceId?: string }) => void; startupOsdSequencer?: { notifyCharacterDictionaryStatus: ( event: StartupOsdSequencerCharacterDictionaryEvent, @@ -17,6 +17,10 @@ export interface CharacterDictionaryAutoSyncNotificationDeps { }; } +// One live desktop notification for the whole sync: progress updates replace each other and the +// terminal ready/failed message replaces the last progress one, matching the overlay toast. +const CHARACTER_DICTIONARY_DESKTOP_NOTIFICATION_ID = 'character-dictionary-auto-sync'; + function isTerminalPhase(phase: CharacterDictionaryAutoSyncNotificationEvent['phase']): boolean { return phase === 'ready' || phase === 'failed'; } @@ -53,7 +57,10 @@ export function notifyCharacterDictionaryAutoSyncStatus( persistent: !isTerminalPhase(event.phase), }); } else if (!shouldShowDesktop(type)) { - deps.showDesktopNotification('SubMiner', { body: event.message }); + deps.showDesktopNotification('SubMiner', { + body: event.message, + replaceId: CHARACTER_DICTIONARY_DESKTOP_NOTIFICATION_ID, + }); } } @@ -69,6 +76,9 @@ export function notifyCharacterDictionaryAutoSyncStatus( } if (shouldShowDesktop(type) && !startupSequencerShown) { - deps.showDesktopNotification('SubMiner', { body: event.message }); + deps.showDesktopNotification('SubMiner', { + body: event.message, + replaceId: CHARACTER_DICTIONARY_DESKTOP_NOTIFICATION_ID, + }); } } diff --git a/src/main/runtime/startup-osd-sequencer.ts b/src/main/runtime/startup-osd-sequencer.ts index 4dd35f66..4a0f0886 100644 --- a/src/main/runtime/startup-osd-sequencer.ts +++ b/src/main/runtime/startup-osd-sequencer.ts @@ -10,7 +10,7 @@ export interface StartupOsdSequencerDeps { getNotificationType?: () => NotificationType | undefined; showOsd: (message: string) => boolean | void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; - showDesktopNotification?: (title: string, options: { body?: string }) => void; + showDesktopNotification?: (title: string, options: { body?: string; replaceId?: string }) => void; } interface StartupStatusNotificationOptions { @@ -62,7 +62,11 @@ export function createStartupOsdSequencer(deps: StartupOsdSequencerDeps): { shown = deps.showOsd(options.message) !== false || shown; } if (options.desktop !== false && shouldShowDesktop(type)) { - deps.showDesktopNotification?.('SubMiner', { body: options.message }); + // Each startup lane keeps one live desktop notification instead of one per update. + deps.showDesktopNotification?.('SubMiner', { + body: options.message, + replaceId: options.id, + }); shown = true; } return shown;