From a7ddd79e981666d27865d9515843b57ac2c257b9 Mon Sep 17 00:00:00 2001 From: "seahaven-openswe[bot]" <296972425+seahaven-openswe[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:53:28 -0400 Subject: [PATCH] feat: prompt-mode PWA update toast instead of autoUpdate reload (#102) --- ui/src/client.tsx | 39 +++++++++++++++++------ ui/src/components/PwaUpdateToast.tsx | 47 ++++++++++++++++++++++++++++ ui/vite.config.ts | 2 +- 3 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 ui/src/components/PwaUpdateToast.tsx diff --git a/ui/src/client.tsx b/ui/src/client.tsx index 4bdeadd3..c6783110 100644 --- a/ui/src/client.tsx +++ b/ui/src/client.tsx @@ -1,21 +1,42 @@ import { StartClient } from "@tanstack/react-start/client" -import { StrictMode } from "react" +import { StrictMode, useEffect, useState } from "react" import { hydrateRoot } from "react-dom/client" import { registerSW } from "virtual:pwa-register" -// autoUpdate: vite-plugin-pwa generates a SW that calls skipWaiting and -// clientsClaim, so a new deploy activates for existing tabs. Open dashboard -// tabs may reload when the service worker takes over; this is acceptable for -// a dev/internal dashboard where silent updates are preferred over stale -// bundles. Skip in dev — the SW precaches production-only build artifacts that -// 404 against the dev server. -if (import.meta.env.PROD) { - registerSW() +import { PwaUpdateToast } from "@/components/PwaUpdateToast" + +// Prompt-mode PWA: vite-plugin-pwa still generates a SW that precaches the +// production bundle, but it does not force skipWaiting/clientsClaim. Instead, +// the new SW waits until the user accepts the update, so open dashboard tabs +// are not reloaded mid-session. This protects in-flight agent runs and any +// unsaved text in the compose box. Skip in dev — the SW precaches production-only +// build artifacts that 404 against the dev server. +function PwaUpdateProvider() { + const [needRefresh, setNeedRefresh] = useState(false) + const [updateSW, setUpdateSW] = useState<(() => Promise) | undefined>(undefined) + + useEffect(() => { + if (!import.meta.env.PROD) return + const updateSWRef = registerSW({ + onNeedRefresh() { + setNeedRefresh(true) + }, + }) + setUpdateSW(() => updateSWRef) + }, []) + + return ( + + ) } hydrateRoot( document, + ) diff --git a/ui/src/components/PwaUpdateToast.tsx b/ui/src/components/PwaUpdateToast.tsx new file mode 100644 index 00000000..792c7207 --- /dev/null +++ b/ui/src/components/PwaUpdateToast.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react" + +import { Button } from "@/components/ui/button" + +interface PwaUpdateToastProps { + needRefresh: boolean + updateServiceWorker: (() => Promise) | undefined +} + +export function PwaUpdateToast({ + needRefresh, + updateServiceWorker, +}: PwaUpdateToastProps) { + const [dismissed, setDismissed] = useState(false) + + useEffect(() => { + if (needRefresh) { + setDismissed(false) + } + }, [needRefresh]) + + if (!needRefresh || dismissed) { + return null + } + + return ( +
+
+

A new version is available.

+

Reload to update the dashboard.

+
+
+ + +
+
+ ) +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 4e2ea978..7b377067 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -173,7 +173,7 @@ const config = defineConfig({ tanstackStart({ spa: { enabled: true } }), VitePWA({ injectRegister: false, - registerType: "autoUpdate", + registerType: "prompt", outDir: ".output/public", devOptions: { enabled: true,