feat(notifications): add notification history panel and overlay UX fixes

- New toggleNotificationHistory (Ctrl+N) session-scoped history panel; slides in from same edge as notification stack
- Overlay error/recovery toast follows notifications.overlayPosition; stack and history side seeded at startup
- Cold managed background startup initializes tray and visible overlay shell before tokenization warmups finish
- Add Update button to overlay update-available notifications
- Fix Ctrl+S sentence-card flow: only Anki progress notification, no duplicate status toast
- Fix overlay notification close/actions clickability above subtitle bars on Linux
- Increase pause-until-ready default timeout from 15s to 30s
This commit is contained in:
2026-06-06 15:29:14 -07:00
parent a34ec049a2
commit 8111deac44
68 changed files with 1408 additions and 69 deletions
@@ -36,6 +36,28 @@ test('notifyUpdateAvailable routes notification surfaces from config', async ()
]);
});
test('notifyUpdateAvailable adds an install action to overlay update notifications', async () => {
const payloads: OverlayNotificationPayload[] = [];
await notifyUpdateAvailable(
{ notificationType: 'overlay', version: '0.15.0' },
{
showSystemNotification: () => {},
showOsdNotification: async () => {},
showOverlayNotification: (nextPayload) => {
payloads.push(nextPayload);
},
log: () => {},
},
);
const payload = payloads[0];
assert.ok(payload);
assert.deepEqual(payload.actions, [{ id: 'install-update', label: 'Update' }]);
assert.equal(payload.id, 'subminer-update-available');
assert.equal(payload.persistent, true);
});
test('notifyUpdateAvailable logs osd fallback when overlay notification fails', async () => {
const calls: string[] = [];
@@ -1,6 +1,9 @@
import type { UpdateNotificationType } from '../../../types/config';
import type { OverlayNotificationPayload } from '../../../types/notification';
export const UPDATE_AVAILABLE_NOTIFICATION_ID = 'subminer-update-available';
export const INSTALL_UPDATE_ACTION_ID = 'install-update';
export interface UpdateNotificationDeps {
showSystemNotification: (title: string, body: string) => void;
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
@@ -17,9 +20,12 @@ export async function notifyUpdateAvailable(
const message = `SubMiner v${options.version} is available`;
if (options.notificationType === 'overlay' || options.notificationType === 'both') {
deps.showOverlayNotification({
id: UPDATE_AVAILABLE_NOTIFICATION_ID,
title: 'SubMiner update available',
body: message,
variant: 'info',
persistent: true,
actions: [{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' }],
});
}
if (options.notificationType === 'osd' || options.notificationType === 'osd-system') {
@@ -96,6 +96,28 @@ test('manual update check falls back to GitHub release when app metadata is unav
assert.deepEqual(calls, ['available-dialog:0.15.0']);
});
test('manual update install request skips available dialog and updates app', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
showUpdateAvailableDialog: async () => {
throw new Error('unexpected update confirmation');
},
updateLauncher: async (_launcherPath, channel) => {
calls.push(`launcher:${channel}`);
return { status: 'skipped' };
},
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({
source: 'manual',
installWhenAvailable: true,
});
assert.equal(result.status, 'updated');
assert.deepEqual(calls, ['download', 'launcher:stable', 'restart-dialog']);
});
test('manual update check reports available when no update asset was applied', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
+6 -3
View File
@@ -15,6 +15,7 @@ export interface UpdateCheckRequest {
source: UpdateCheckSource;
force?: boolean;
launcherPath?: string;
installWhenAvailable?: boolean;
}
export type UpdateCheckStatus =
@@ -164,9 +165,11 @@ export function createUpdateService(deps: UpdateServiceDeps) {
return { status: 'update-available', version: latest.version };
}
const choice = await deps.showUpdateAvailableDialog(latest.version);
if (choice === 'close') {
return { status: 'update-available', version: latest.version };
if (!request.installWhenAvailable) {
const choice = await deps.showUpdateAvailableDialog(latest.version);
if (choice === 'close') {
return { status: 'update-available', version: latest.version };
}
}
const canInstallAppUpdate = appUpdate.available && appUpdate.canUpdate !== false;