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.
This commit is contained in:
Jinjing
2026-09-14 14:49:00 -07:00
parent 6eddfd9f46
commit d6285bd5a7
2 changed files with 18 additions and 3 deletions
@@ -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.')
@@ -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<ImportSkipReason, { key: string; fallback: string }> = {
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
}
)
}