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
@@ -0,0 +1,241 @@
import type { OverlayNotificationVariant } from '../types';
import type { RendererContext } from './context';
import type { OverlayNotificationEntry } from './overlay-notifications.js';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
export const DEFAULT_OVERLAY_NOTIFICATION_HISTORY_MAX = 200;
const OVERLAY_NOTIFICATION_HISTORY_VARIANT_CLASSES = [
'info',
'progress',
'success',
'warning',
'error',
] as const;
export type OverlayNotificationHistoryEntry = {
id: string;
title: string;
body?: string;
image?: string;
variant: OverlayNotificationVariant;
createdAt: number;
updatedAt: number;
};
export type OverlayNotificationHistoryStoreOptions = {
max?: number;
now?: () => number;
};
function normalizeVariant(
variant: OverlayNotificationVariant | undefined,
): OverlayNotificationVariant {
return variant ?? 'info';
}
/**
* Session-scoped log of every overlay notification that was shown. Entries are keyed by id so a
* progress notification that updates in place (same id, new body) overwrites its record rather than
* piling up duplicates. Ordering is by first-seen so the panel can render newest-first.
*/
export function createOverlayNotificationHistoryStore(
options: OverlayNotificationHistoryStoreOptions = {},
) {
const max = Math.max(1, options.max ?? DEFAULT_OVERLAY_NOTIFICATION_HISTORY_MAX);
const now = options.now ?? (() => Date.now());
const entries = new Map<string, OverlayNotificationHistoryEntry>();
function record(entry: OverlayNotificationEntry): OverlayNotificationHistoryEntry {
const timestamp = now();
const existing = entries.get(entry.id);
const next: OverlayNotificationHistoryEntry = {
id: entry.id,
title: entry.title,
body: entry.body,
image: entry.image,
variant: normalizeVariant(entry.variant),
createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp,
};
// Setting an existing key keeps its original insertion slot, so an in-place update (same id,
// new body) refreshes content without jumping the entry to the top of the panel.
entries.set(entry.id, next);
while (entries.size > max) {
const oldest = entries.keys().next().value;
if (oldest === undefined) break;
entries.delete(oldest);
}
return next;
}
function remove(id: string): void {
entries.delete(id);
}
function clear(): void {
entries.clear();
}
function list(): OverlayNotificationHistoryEntry[] {
// Newest first.
return [...entries.values()].reverse();
}
function size(): number {
return entries.size;
}
return { record, remove, clear, list, size };
}
export type OverlayNotificationHistorySide = 'left' | 'right';
/**
* The history panel slides in from the same edge the notifications use: left when notifications are
* top-left, right otherwise (including center). We read the live position class off the notification
* stack so the panel always tracks the configured/last-used position.
*/
export function resolveHistorySideFromStack(stack: Element): OverlayNotificationHistorySide {
return stack.classList.contains('position-top-left') ? 'left' : 'right';
}
export function createOverlayNotificationHistoryPanel(
ctx: RendererContext,
options: { onChanged?: () => void } = {},
) {
const store = createOverlayNotificationHistoryStore();
const panel = ctx.dom.overlayNotificationHistory;
const list = panel.querySelector<HTMLUListElement>('.notification-history-list');
const empty = panel.querySelector<HTMLElement>('.notification-history-empty');
const clearButton = panel.querySelector<HTMLButtonElement>('.notification-history-clear');
const closeButton = panel.querySelector<HTMLButtonElement>('.notification-history-close');
let open = false;
function setInteractive(value: boolean): void {
ctx.state.isOverNotificationHistory = value;
syncOverlayMouseIgnoreState(ctx);
}
function applySide(): void {
const side = resolveHistorySideFromStack(ctx.dom.overlayNotificationStack);
panel.classList.toggle('side-left', side === 'left');
panel.classList.toggle('side-right', side === 'right');
}
function formatTime(timestamp: number): string {
try {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return '';
}
}
function buildItem(entry: OverlayNotificationHistoryEntry): HTMLLIElement {
const item = document.createElement('li');
item.className = 'notification-history-item';
for (const variant of OVERLAY_NOTIFICATION_HISTORY_VARIANT_CLASSES) {
item.classList.toggle(variant, variant === entry.variant);
}
item.dataset.notificationId = entry.id;
const trimmedImage = entry.image?.trim();
const leading = trimmedImage ? document.createElement('img') : document.createElement('span');
leading.className = trimmedImage ? 'notification-history-thumb' : 'notification-history-icon';
leading.setAttribute('aria-hidden', 'true');
if (trimmedImage) {
const image = leading as HTMLImageElement;
image.src = trimmedImage;
image.alt = '';
image.decoding = 'async';
}
const content = document.createElement('div');
content.className = 'notification-history-content';
const title = document.createElement('div');
title.className = 'notification-history-item-title';
title.textContent = entry.title;
content.append(title);
if (entry.body && entry.body.trim().length > 0) {
const body = document.createElement('div');
body.className = 'notification-history-item-body';
body.textContent = entry.body;
content.append(body);
}
const time = document.createElement('time');
time.className = 'notification-history-time';
time.dateTime = new Date(entry.createdAt).toISOString();
time.textContent = formatTime(entry.createdAt);
content.append(time);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'notification-history-remove';
remove.setAttribute('aria-label', 'Remove from history');
remove.textContent = '×';
remove.addEventListener('click', () => {
store.remove(entry.id);
render();
});
item.append(leading, content, remove);
return item;
}
function render(): void {
if (!list || !empty) return;
const entries = store.list();
list.replaceChildren(...entries.map(buildItem));
empty.classList.toggle('hidden', entries.length > 0);
if (clearButton) clearButton.disabled = entries.length === 0;
options.onChanged?.();
}
function setOpen(next: boolean): void {
if (open === next) return;
open = next;
ctx.state.notificationHistoryOpen = next;
if (next) {
applySide();
render();
}
panel.classList.toggle('open', next);
panel.setAttribute('aria-hidden', next ? 'false' : 'true');
setInteractive(next);
options.onChanged?.();
}
clearButton?.addEventListener('click', () => {
store.clear();
render();
});
closeButton?.addEventListener('click', () => setOpen(false));
panel.addEventListener('mouseenter', () => {
if (open) setInteractive(true);
});
panel.addEventListener('mouseleave', () => setInteractive(false));
function record(entry: OverlayNotificationEntry): void {
store.record(entry);
if (open) render();
}
function toggle(): void {
setOpen(!open);
}
return {
record,
toggle,
open: () => setOpen(true),
close: () => setOpen(false),
isOpen: () => open,
};
}