From d6285bd5a7841728cdc08402fb56d328f40a2fbb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:49:00 -0700 Subject: [PATCH] Reuse single toast slot for composer drop failures Multiple drop failures now replace the previous toast instead of stacking, preventing notification clutter. Uses a dedicated toast ID separate from Source Control's stage/discard notifications. --- .../src/hooks/composer-drop-failure-toast.test.ts | 13 +++++++++++-- .../src/hooks/composer-drop-failure-toast.ts | 8 +++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/hooks/composer-drop-failure-toast.test.ts b/src/renderer/src/hooks/composer-drop-failure-toast.test.ts index 6c8c03d6b45..ab2945065cb 100644 --- a/src/renderer/src/hooks/composer-drop-failure-toast.test.ts +++ b/src/renderer/src/hooks/composer-drop-failure-toast.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { toastError } = vi.hoisted(() => ({ - toastError: vi.fn<(title: string, options?: { description?: string }) => void>() + toastError: vi.fn<(title: string, options?: { id?: string; description?: string }) => void>() })) vi.mock('sonner', () => ({ toast: { error: toastError } })) @@ -15,10 +15,11 @@ const SKIP_REASON_COPY = [ ['unsupported', 'Unsupported file type.'] ] as const satisfies readonly (readonly [ImportSkipReason, string])[] -function lastToast(): { title: string; description?: string } { +function lastToast(): { title: string; id?: string; description?: string } { const call = toastError.mock.calls.at(-1) return { title: String(call?.[0]), + id: call?.[1]?.id, description: call?.[1]?.description } } @@ -74,6 +75,14 @@ describe('showComposerDropFailureToast', () => { expect(lastToast().description).toBe('EACCES: permission denied') }) + it('reuses one slot so a second failed drop replaces the first instead of stacking', () => { + showComposerDropFailureToast({ failureCount: 1, total: 2 }) + const first = lastToast().id + showComposerDropFailureToast({ failureCount: 2, total: 3 }) + expect(first).toBeDefined() + expect(lastToast().id).toBe(first) + }) + it('gives no reason at all when the batch failed for differing reasons', () => { showComposerDropFailureToast({ failureCount: 3, total: 6 }) expect(lastToast().title).toBe('3 of 6 items could not be attached.') diff --git a/src/renderer/src/hooks/composer-drop-failure-toast.ts b/src/renderer/src/hooks/composer-drop-failure-toast.ts index f607bf31d4a..a3dde2b7493 100644 --- a/src/renderer/src/hooks/composer-drop-failure-toast.ts +++ b/src/renderer/src/hooks/composer-drop-failure-toast.ts @@ -4,6 +4,9 @@ import { compactIpcErrorMessage } from '@/lib/ipc-error' import type { ComposerDropFailure } from './composer-drop-result' import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types' +// Own slot, not Source Control's: a drop failure must not erase an unread stage/discard failure. +const DROP_FAILURE_TOAST_ID = 'composer-drop-failure' + const SKIP_REASON_COPY: Record = { missing: { key: 'auto.hooks.useComposerState.attachSkipMissing', @@ -46,6 +49,9 @@ export function showComposerDropFailureToast({ '{{failureCount}} of {{count}} items could not be attached.', { failureCount, count: total } ), - { description: commonFailure ? failureDescription(commonFailure) : undefined } + { + id: DROP_FAILURE_TOAST_ID, + description: commonFailure ? failureDescription(commonFailure) : undefined + } ) }