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
This commit is contained in:
2026-08-10 23:09:48 -07:00
parent 0e8599dd1d
commit 155c27aac9
4 changed files with 102 additions and 6 deletions
+17 -1
View File
@@ -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<string, Electron.Notification>();
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();
}
@@ -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<string | undefined> = [];
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<string | undefined> = [];
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'],
);
});
@@ -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,
});
}
}
+6 -2
View File
@@ -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;