diff --git a/src/app/components/Toaster.module.css b/src/app/components/Toaster.module.css
index b7879e5..3553745 100644
--- a/src/app/components/Toaster.module.css
+++ b/src/app/components/Toaster.module.css
@@ -142,6 +142,31 @@
color: var(--text-secondary);
}
+.actions {
+ margin-top: var(--space-3);
+}
+
+.action {
+ padding: var(--space-1) var(--space-3);
+ font: inherit;
+ font-size: 13px;
+ color: var(--toast-accent);
+ background: transparent;
+ border: var(--border-width) solid var(--toast-accent);
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: background var(--dur-fast) var(--ease);
+}
+
+.action:hover {
+ background: var(--layer-01);
+}
+
+.action:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 2px;
+}
+
.close {
flex: none;
display: inline-flex;
diff --git a/src/app/components/Toaster.test.tsx b/src/app/components/Toaster.test.tsx
index 466a470..8e2d635 100644
--- a/src/app/components/Toaster.test.tsx
+++ b/src/app/components/Toaster.test.tsx
@@ -171,6 +171,45 @@ describe('Toaster exit animation (two-phase dismiss)', () => {
vi.useRealTimers();
});
+ test('a durable toast does not auto-dismiss, and its action runs then dismisses it', () => {
+ vi.useFakeTimers();
+ const onClick = vi.fn();
+ let id: string;
+ act(() => {
+ id = useNotificationStore.getState().notify({
+ kind: 'info',
+ title: 'Update available',
+ message: 'A new version is ready.',
+ durable: true,
+ action: { label: 'Reload', onClick },
+ });
+ root.render();
+ });
+
+ // Well past the auto-dismiss window — still present (durable).
+ act(() => {
+ vi.advanceTimersByTime(60000);
+ });
+ expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true);
+
+ // The action button runs the handler, then the toast exits.
+ const actionBtn = Array.from(container.querySelectorAll('button')).find(
+ (b) => b.textContent === 'Reload',
+ );
+ expect(actionBtn).toBeTruthy();
+ act(() => {
+ actionBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ vi.advanceTimersByTime(TOAST_EXIT_MS);
+ });
+ expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(false);
+
+ vi.useRealTimers();
+ });
+
test('clicking close twice does not reset the exit timer', () => {
vi.useFakeTimers();
let id: string;
diff --git a/src/app/components/Toaster.tsx b/src/app/components/Toaster.tsx
index fc77cfc..d6df57c 100644
--- a/src/app/components/Toaster.tsx
+++ b/src/app/components/Toaster.tsx
@@ -70,18 +70,25 @@ function Toast({
exiting: boolean;
onStartExit: (id: string) => void;
}) {
- const { id, kind, title, message, detail } = notification;
+ const { id, kind, title, message, detail, action, durable } = notification;
// Trigger exit animation; after the animation duration the store removes it.
const handleDismiss = useCallback(() => onStartExit(id), [id, onStartExit]);
- // Positive/informational toasts clear themselves after a beat. Resets on
- // re-render only when id/kind changes (stable identity guarantee).
+ // Positive/informational toasts clear themselves after a beat — unless marked
+ // durable (e.g. an update prompt the user must act on). Resets only when
+ // id/kind changes (stable identity guarantee).
useEffect(() => {
- if (!AUTO_DISMISS[kind]) return;
+ if (!AUTO_DISMISS[kind] || durable) return;
const timer = setTimeout(handleDismiss, AUTO_DISMISS_MS);
return () => clearTimeout(timer);
- }, [id, kind, handleDismiss]);
+ }, [id, kind, durable, handleDismiss]);
+
+ // Run the action, then dismiss the toast (the handler may navigate/reload).
+ const handleAction = useCallback(() => {
+ action?.onClick();
+ handleDismiss();
+ }, [action, handleDismiss]);
return (
Astrolabe v{__APP_VERSION__}
)}
+ {action && (
+
+
+
+ )}