From 299675819824f14b65fee357f84dc6d35664dc3d Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:49:13 -0400 Subject: [PATCH 1/2] fix(pwa): show a persistent update banner instead of a toast The service worker update notice was a dismissible toast that was easy to miss, leaving users on a stale build. Render a persistent status bar above the app shell with an explicit Reload action, and switch the shell to a flex column so pages size against the shell height (h-full) rather than the viewport (h-dvh), keeping the layout correct while the banner is visible. --- frontend/src/App.tsx | 14 ++-- .../src/components/PwaUpdatePrompt.test.tsx | 73 +++++++++++++++++++ frontend/src/components/PwaUpdatePrompt.tsx | 43 +++++++---- frontend/src/components/ui/sidebar.tsx | 2 +- frontend/src/pages/AssistantRedirect.tsx | 4 +- frontend/src/pages/GlobalSchedules.tsx | 2 +- frontend/src/pages/Login.tsx | 2 +- frontend/src/pages/Register.tsx | 2 +- frontend/src/pages/RepoDetail.tsx | 4 +- frontend/src/pages/Repos.tsx | 4 +- frontend/src/pages/Schedules.tsx | 2 +- frontend/src/pages/SessionDetail.tsx | 2 +- frontend/src/pages/Setup.tsx | 2 +- 13 files changed, 124 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/PwaUpdatePrompt.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 06d5782e9..3cd9d97bf 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 000000000..e90ddf156 --- /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 90871141e..996b460ee 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 f03a2ab66..2a5f54792 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -25,7 +25,7 @@ export function Sidebar({ return (