fix(web): toasts with an action or link stay ~10s (TASK-3973) (#2245)

A toast that offers an action button or a link now defaults to
ACTIONABLE_DURATION (10s, the same as an error) when the caller passes no
duration, so quick add's "Open" is still there when a person reaches for
it. An explicit duration still wins. Hover and focus still pause the clock.


Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
xarmian
2026-10-19 20:48:18 -04:00
committed by GitHub
parent 5eeca6f730
commit 1bbca0753f
2 changed files with 48 additions and 2 deletions
+9 -1
View File
@@ -33,6 +33,13 @@ const DEFAULT_DURATION = 3000;
* duration; an explicit duration still wins.
*/
export const ERROR_DURATION = 10000;
/**
* A toast that offers something to do (an action button or a link) lingers as
* long as an error (TASK-3973): at 3s the "Open" on a quick-add confirmation
* was gone before a person could reach it. Applied when the caller passes no
* duration; an explicit duration still wins.
*/
export const ACTIONABLE_DURATION = ERROR_DURATION;
/**
* Test-surface kill switch for CROSS-ACTOR notification toasts (BUG-2334).
@@ -110,7 +117,8 @@ function show(
action?: ToastAction,
opts: ShowOptions = {},
): string {
duration ??= type === 'error' ? ERROR_DURATION : DEFAULT_DURATION;
duration ??=
type === 'error' ? ERROR_DURATION : action || link ? ACTIONABLE_DURATION : DEFAULT_DURATION;
const id = generateId();
const toast: Toast = { id, message, type, duration, link, action };
+39 -1
View File
@@ -1,7 +1,10 @@
// TASK-2202 — errors linger, and a toast someone is reading or reaching for
// does not vanish under them. Fake timers, so durations are exact.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { toastStore, ERROR_DURATION } from './toast.svelte';
import { toastStore, ERROR_DURATION, ACTIONABLE_DURATION } from './toast.svelte';
import { announceCaptured } from '$lib/items/quickCapture';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
const present = (id: string) => toastStore.toasts.some((t) => t.id === id);
@@ -36,6 +39,41 @@ describe('toast durations (TASK-2202)', () => {
});
});
describe('toasts that offer something to do linger (TASK-3973)', () => {
const act = { label: 'Undo', onAction: () => {} };
it('a toast with an action and no duration stays for ACTIONABLE_DURATION', () => {
expect(ACTIONABLE_DURATION).toBeGreaterThanOrEqual(8000);
const id = toastStore.show('Archived 3 items', 'success', undefined, undefined, act);
vi.advanceTimersByTime(ACTIONABLE_DURATION - 1);
expect(present(id)).toBe(true);
vi.advanceTimersByTime(1);
expect(present(id)).toBe(false);
});
it('a toast with only a link stays for ACTIONABLE_DURATION', () => {
const id = toastStore.show('Imported DOC-4', 'info', undefined, '/w/docs/doc-4');
vi.advanceTimersByTime(ACTIONABLE_DURATION - 1);
expect(present(id)).toBe(true);
vi.advanceTimersByTime(1);
expect(present(id)).toBe(false);
});
it('an explicit duration still wins over the actionable default', () => {
const id = toastStore.show('Archived', 'success', 2000, '/w/x', act);
vi.advanceTimersByTime(2000);
expect(present(id)).toBe(false);
});
it("quick add's Open toast survives 9s", () => {
announceCaptured({ title: 'Buy milk', collection_prefix: 'TASK', item_number: 12 }, '/w/tasks/task-12');
const toast = toastStore.toasts.find((t) => t.message === 'Added TASK-12');
expect(toast?.action?.label).toBe('Open');
vi.advanceTimersByTime(9000);
expect(present(toast!.id), 'still up at 9s').toBe(true);
});
});
describe('toast hold (TASK-2202)', () => {
it('a paused toast stays, and resumes with only what was left', () => {
const id = toastStore.show('Saved', 'success', 3000);