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 501304e451
commit d033884b09
68 changed files with 1408 additions and 69 deletions
+69
View File
@@ -2,6 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
function waitTurn(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
const calls: string[] = [];
const deps = {
@@ -277,6 +281,71 @@ test('runAppReadyRuntime does not await background warmups', async () => {
releaseWarmup();
});
test('runAppReadyRuntime handles managed background initial args before deferred Yomitan wait', async () => {
const calls: string[] = [];
let releaseYomitan!: () => void;
const yomitanGate = new Promise<void>((resolve) => {
releaseYomitan = resolve;
});
const { deps } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
loadYomitanExtension: async () => {
calls.push('loadYomitanExtension:start');
await yomitanGate;
calls.push('loadYomitanExtension:done');
},
handleFirstRunSetup: async () => {
calls.push('handleFirstRunSetup');
},
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
} as Partial<AppReadyRuntimeDeps>);
const readyPromise = runAppReadyRuntime(deps);
await waitTurn();
try {
assert.ok(calls.includes('handleFirstRunSetup'));
assert.ok(calls.includes('handleInitialArgs'));
assert.equal(calls.includes('loadYomitanExtension:done'), false);
} finally {
releaseYomitan();
await readyPromise;
}
});
test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomitan readiness', async () => {
const calls: string[] = [];
let releaseYomitan!: () => void;
const yomitanGate = new Promise<void>((resolve) => {
releaseYomitan = resolve;
});
const { deps } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => false,
loadYomitanExtension: async () => {
calls.push('loadYomitanExtension:start');
await yomitanGate;
calls.push('loadYomitanExtension:done');
},
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
} as Partial<AppReadyRuntimeDeps>);
const readyPromise = runAppReadyRuntime(deps);
await waitTurn();
assert.equal(calls.includes('handleInitialArgs'), false);
releaseYomitan();
await readyPromise;
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
});
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
const calls: string[] = [];
const { deps } = makeDeps({
+31
View File
@@ -137,6 +137,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async () => {},
@@ -242,6 +243,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: () => {},
@@ -552,6 +554,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: () => {},
@@ -977,6 +980,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: (update) => {
@@ -1058,6 +1062,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async (update) => {
@@ -1262,6 +1267,31 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () =>
]);
});
test('registerIpcHandlers forwards valid overlay notification actions', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const actions: Array<{ notificationId: string; actionId: string }> = [];
registerIpcHandlers(
createRegisterIpcDeps({
handleOverlayNotificationAction: (notificationId: string, actionId: string) => {
actions.push({ notificationId, actionId });
},
} as Partial<IpcServiceDeps>),
registrar,
);
const actionHandler = handlers.on.get(IPC_CHANNELS.command.overlayNotificationAction);
assert.ok(actionHandler);
actionHandler({}, null);
actionHandler({}, { notificationId: '', actionId: 'install-update' });
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 42 });
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 'install-update' });
assert.deepEqual(actions, [
{ notificationId: 'subminer-update-available', actionId: 'install-update' },
]);
});
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(
@@ -1289,6 +1319,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async () => {},
+40
View File
@@ -53,6 +53,10 @@ export interface IpcServiceDeps {
interactive: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
handleOverlayNotificationAction?: (
notificationId: string,
actionId: string,
) => void | Promise<void>;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
@@ -80,6 +84,7 @@ export interface IpcServiceDeps {
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string;
getMarkWatchedKey: () => string;
getOverlayNotificationPosition: () => string;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
@@ -223,6 +228,18 @@ function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | n
return parsed;
}
function parseOverlayNotificationActionPayload(
payload: unknown,
): { notificationId: string; actionId: string } | null {
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
const notificationId = record.notificationId;
const actionId = record.actionId;
if (typeof notificationId !== 'string' || notificationId.trim().length === 0) return null;
if (typeof actionId !== 'string' || actionId.trim().length === 0) return null;
return { notificationId, actionId };
}
export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean;
@@ -242,6 +259,10 @@ export interface IpcDepsRuntimeOptions {
interactive: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
handleOverlayNotificationAction?: (
notificationId: string,
actionId: string,
) => void | Promise<void>;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
@@ -262,6 +283,7 @@ export interface IpcDepsRuntimeOptions {
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string;
getMarkWatchedKey: () => string;
getOverlayNotificationPosition: () => string;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
@@ -312,6 +334,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
onOverlayInteractiveHint: options.onOverlayInteractiveHint,
handleOverlayNotificationAction: options.handleOverlayNotificationAction,
openYomitanSettings: options.openYomitanSettings,
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
quitApp: options.quitApp,
@@ -349,6 +372,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
getStatsToggleKey: options.getStatsToggleKey,
getMarkWatchedKey: options.getMarkWatchedKey,
getOverlayNotificationPosition: options.getOverlayNotificationPosition,
getControllerConfig: options.getControllerConfig,
saveControllerConfig: options.saveControllerConfig,
saveControllerPreference: options.saveControllerPreference,
@@ -473,6 +497,18 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalOpened(parsedModal, senderWindow);
});
ipc.on(IPC_CHANNELS.command.overlayNotificationAction, (_event: unknown, payload: unknown) => {
const parsedPayload = parseOverlayNotificationActionPayload(payload);
if (!parsedPayload) return;
void Promise.resolve(
deps.handleOverlayNotificationAction?.(parsedPayload.notificationId, parsedPayload.actionId),
).catch((error) => {
console.warn(
'Failed to handle overlay notification action:',
error instanceof Error ? error.message : String(error),
);
});
});
ipc.handle(
IPC_CHANNELS.request.youtubePickerResolve,
@@ -641,6 +677,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getMarkWatchedKey();
});
ipc.handle(IPC_CHANNELS.request.getOverlayNotificationPosition, () => {
return deps.getOverlayNotificationPosition();
});
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
return deps.getControllerConfig();
});
@@ -32,6 +32,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
...overrides,
};
}
@@ -27,6 +27,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
...overrides,
};
}
@@ -25,6 +25,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
mineSentenceCount: (count) => calls.push(`mine:${count}`),
toggleSecondarySub: () => calls.push('secondary'),
toggleSubtitleSidebar: () => calls.push('sidebar'),
toggleNotificationHistory: () => calls.push('notification-history'),
markLastCardAsAudioCard: async () => {
calls.push('audio');
},
+4
View File
@@ -14,6 +14,7 @@ export interface SessionActionExecutorDeps {
mineSentenceCount: (count: number) => void;
toggleSecondarySub: () => void;
toggleSubtitleSidebar: () => void;
toggleNotificationHistory: () => void;
markLastCardAsAudioCard: () => Promise<void>;
markActiveVideoWatched: () => Promise<boolean>;
openRuntimeOptionsPalette: () => void;
@@ -79,6 +80,9 @@ export async function dispatchSessionAction(
case 'toggleSubtitleSidebar':
deps.toggleSubtitleSidebar();
return;
case 'toggleNotificationHistory':
deps.toggleNotificationHistory();
return;
case 'markAudioCard':
await deps.markLastCardAsAudioCard();
return;
+5 -1
View File
@@ -26,6 +26,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
...overrides,
};
}
@@ -195,7 +196,10 @@ test('compileSessionBindings keeps mouse buttons scoped to keybindings', () => {
platform: 'win32',
});
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
assert.deepEqual(
result.bindings.map((binding) => binding.sourcePath),
['keybindings[0].key'],
);
assert.deepEqual(
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
['unsupported:shortcuts.openJimaku'],
+1
View File
@@ -59,6 +59,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
{ key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' },
{ key: 'toggleNotificationHistory', actionId: 'toggleNotificationHistory' },
];
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
+29 -6
View File
@@ -158,6 +158,7 @@ export interface AppReadyRuntimeDeps {
shouldRunHeadlessInitialCommand?: () => boolean;
shouldUseMinimalStartup?: () => boolean;
shouldSkipHeavyStartup?: () => boolean;
shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: () => boolean;
}
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
@@ -229,6 +230,23 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const startupStartedAtMs = now();
const ensureYomitanExtensionReady =
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
let firstRunSetupHandled = false;
let initialArgsHandled = false;
const handleFirstRunSetupOnce = async (): Promise<void> => {
if (firstRunSetupHandled) {
return;
}
firstRunSetupHandled = true;
await deps.handleFirstRunSetup();
};
const handleInitialArgsOnce = (): void => {
if (initialArgsHandled) {
return;
}
initialArgsHandled = true;
deps.handleInitialArgs();
};
deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) {
deps.reloadConfig();
@@ -247,7 +265,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.shouldUseMinimalStartup?.()) {
deps.reloadConfig();
deps.handleInitialArgs();
handleInitialArgsOnce();
return;
}
@@ -256,8 +274,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.shouldSkipHeavyStartup?.()) {
await ensureYomitanExtensionReady();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
await handleFirstRunSetupOnce();
handleInitialArgsOnce();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
return;
}
@@ -332,10 +350,15 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.initializeOverlayRuntime();
} else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
await ensureYomitanExtensionReady();
if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) {
await handleFirstRunSetupOnce();
handleInitialArgsOnce();
} else {
await ensureYomitanExtensionReady();
}
}
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
await handleFirstRunSetupOnce();
handleInitialArgsOnce();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
}
+2
View File
@@ -19,6 +19,7 @@ export interface ConfiguredShortcuts {
openControllerSelect: string | null | undefined;
openControllerDebug: string | null | undefined;
toggleSubtitleSidebar: string | null | undefined;
toggleNotificationHistory: string | null | undefined;
}
export function resolveConfiguredShortcuts(
@@ -67,5 +68,6 @@ export function resolveConfiguredShortcuts(
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')),
toggleNotificationHistory: normalizeShortcut(shortcutValue('toggleNotificationHistory')),
};
}