diff --git a/app/src/components/AppUpdatePrompt.tsx b/app/src/components/AppUpdatePrompt.tsx index 87a945d87..b3d3c808c 100644 --- a/app/src/components/AppUpdatePrompt.tsx +++ b/app/src/components/AppUpdatePrompt.tsx @@ -13,7 +13,7 @@ * Visual conventions mirror `LocalAIDownloadSnackbar` — bottom-right portal, * stone-900 panel, primary gradient progress bar. */ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useAppUpdate } from '../hooks/useAppUpdate'; @@ -48,12 +48,14 @@ const AppUpdatePrompt = (props: AppUpdatePromptProps) => { const [dismissed, setDismissed] = useState(false); const [prevPhase, setPrevPhase] = useState(phase); - // Re-show on every transition INTO a visible phase, even if the user had - // dismissed a previous error/prompt earlier in the session. + const dismissedErrorRef = useRef(null); + const currentErrorKey = error ?? 'Update failed. See logs for details.'; + // Re-show on every transition INTO a visible non-error phase, or when a new + // error differs from the one the user already dismissed this session. if (phase !== prevPhase) { setPrevPhase(phase); if (shouldShow(phase) && !shouldShow(prevPhase)) { - setDismissed(false); + setDismissed(phase === 'error' && dismissedErrorRef.current === currentErrorKey); } } @@ -66,15 +68,17 @@ const AppUpdatePrompt = (props: AppUpdatePromptProps) => { }, []); const handleRetryDownload = useCallback(() => { + dismissedErrorRef.current = null; setDismissed(false); reset(); void download(); }, [reset, download]); const handleDismissError = useCallback(() => { + dismissedErrorRef.current = currentErrorKey; reset(); setDismissed(true); - }, [reset]); + }, [currentErrorKey, reset]); if (!shouldShow(phase) || dismissed) return null; diff --git a/app/src/components/__tests__/AppUpdatePrompt.test.tsx b/app/src/components/__tests__/AppUpdatePrompt.test.tsx index 23327adc4..848901cd6 100644 --- a/app/src/components/__tests__/AppUpdatePrompt.test.tsx +++ b/app/src/components/__tests__/AppUpdatePrompt.test.tsx @@ -8,7 +8,7 @@ * (`ready_to_install`) * - error surface with retry path */ -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { renderWithProviders } from '../../test/test-utils'; @@ -225,6 +225,27 @@ describe('AppUpdatePrompt', () => { }); }); + it('does not re-open the same dismissed update error on the next background cycle', async () => { + renderWithProviders( + + ); + await waitFor(() => expect(statusListeners.length).toBeGreaterThan(0)); + + emitStatus('error'); + await waitFor(() => expect(screen.getByText('Update failed')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /^Dismiss$/i })); + await waitFor(() => { + expect(screen.queryByText('Update failed')).not.toBeInTheDocument(); + }); + + await act(async () => { + emitStatus('checking'); + emitStatus('error'); + }); + expect(screen.queryByText('Update failed')).not.toBeInTheDocument(); + }); + it('renders nothing when not in Tauri', async () => { mockIsTauri.mockReturnValue(false);