From 1bbca0753f213c2b18e8dd9535fa8fe7bb98deaa Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 19 Oct 2026 20:48:18 -0400 Subject: [PATCH] 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) --- web/src/lib/stores/toast.svelte.ts | 10 ++++- .../lib/stores/toast.timing.svelte.test.ts | 40 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/web/src/lib/stores/toast.svelte.ts b/web/src/lib/stores/toast.svelte.ts index e68abca94..dad1b4345 100644 --- a/web/src/lib/stores/toast.svelte.ts +++ b/web/src/lib/stores/toast.svelte.ts @@ -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 }; diff --git a/web/src/lib/stores/toast.timing.svelte.test.ts b/web/src/lib/stores/toast.timing.svelte.test.ts index 8dbbb5a51..63d847065 100644 --- a/web/src/lib/stores/toast.timing.svelte.test.ts +++ b/web/src/lib/stores/toast.timing.svelte.test.ts @@ -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);