diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 06d5782e..3cd9d97b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -197,11 +197,14 @@ function AppShell() { -
- -
- -
+
+ +
+ +
+ +
+
@@ -210,7 +213,6 @@ function AppShell() { - diff --git a/frontend/src/components/PwaUpdatePrompt.test.tsx b/frontend/src/components/PwaUpdatePrompt.test.tsx new file mode 100644 index 00000000..e90ddf15 --- /dev/null +++ b/frontend/src/components/PwaUpdatePrompt.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { PwaUpdatePrompt } from './PwaUpdatePrompt' + +const mocks = vi.hoisted(() => ({ + onServiceWorkerUpdate: vi.fn(), + offServiceWorkerUpdate: vi.fn(), +})) + +vi.mock('@/lib/serviceWorker', () => ({ + onServiceWorkerUpdate: mocks.onServiceWorkerUpdate, + offServiceWorkerUpdate: mocks.offServiceWorkerUpdate, +})) + +function triggerUpdate() { + const callback = mocks.onServiceWorkerUpdate.mock.calls[0]?.[0] + if (typeof callback !== 'function') throw new Error('update callback was not registered') + act(() => callback()) +} + +describe('PwaUpdatePrompt', () => { + let reloadMock: ReturnType + + beforeEach(() => { + mocks.onServiceWorkerUpdate.mockReset() + mocks.offServiceWorkerUpdate.mockReset() + reloadMock = vi.fn() + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { ...window.location, reload: reloadMock }, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders nothing until an update is signalled', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows a status bar with a Reload button when an update is signalled', () => { + render() + + triggerUpdate() + + expect(screen.getByRole('status')).toHaveTextContent( + 'A new version of OpenCode Manager is available.', + ) + expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument() + }) + + it('reloads the page when Reload is clicked', () => { + render() + + triggerUpdate() + fireEvent.click(screen.getByRole('button', { name: 'Reload' })) + + expect(reloadMock).toHaveBeenCalledTimes(1) + }) + + it('unsubscribes from updates on unmount', () => { + const { unmount } = render() + + unmount() + + expect(mocks.offServiceWorkerUpdate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/components/PwaUpdatePrompt.tsx b/frontend/src/components/PwaUpdatePrompt.tsx index 90871141..867673df 100644 --- a/frontend/src/components/PwaUpdatePrompt.tsx +++ b/frontend/src/components/PwaUpdatePrompt.tsx @@ -1,21 +1,38 @@ -import { useEffect } from 'react' -import { showToast } from '@/lib/toast' +import { useEffect, useState } from 'react' +import { RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' import { onServiceWorkerUpdate, offServiceWorkerUpdate } from '@/lib/serviceWorker' export function PwaUpdatePrompt() { + const [visible, setVisible] = useState(false) + useEffect(() => { - onServiceWorkerUpdate(() => { - showToast.info('New build deployed', { - description: 'Refresh to load the latest changes.', - action: { - label: 'Refresh', - onClick: () => window.location.reload(), - }, - duration: Infinity, - }) - }) + onServiceWorkerUpdate(() => setVisible(true)) return () => offServiceWorkerUpdate() }, []) - return null + if (!visible) return null + + return ( +
+
+
+
+ ) } diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index f03a2ab6..2a5f5479 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -25,7 +25,7 @@ export function Sidebar({ return (