diff --git a/packages/app/index.html b/packages/app/index.html
index 8fad7efb3a45..dfcb1e32bbad 100644
--- a/packages/app/index.html
+++ b/packages/app/index.html
@@ -13,6 +13,7 @@
+
diff --git a/packages/app/public/sw.js b/packages/app/public/sw.js
new file mode 100644
index 000000000000..f1ca81a4f466
--- /dev/null
+++ b/packages/app/public/sw.js
@@ -0,0 +1,90 @@
+///
+const CACHE_NAME = "opencode-v1"
+
+/** @param {string} url */
+function isHashedAsset(url) {
+ return /\/assets\/[^/]+[-.][\da-f]{8,}\.\w+$/.test(url)
+}
+
+/** @param {string} url */
+function isStaticAsset(url) {
+ return /\.(?:js|css|woff2?|png|svg|ico|webmanifest)(?:\?|$)/.test(url)
+}
+
+/** @param {string} url */
+function isAPIorEvent(url) {
+ const path = new URL(url).pathname
+ return path.startsWith("/api/") || path.endsWith("/event") || path.endsWith("/prompt_async")
+}
+
+self.addEventListener("install", () => {
+ self.skipWaiting()
+})
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
+ ),
+ )
+ self.clients.claim()
+})
+
+self.addEventListener("fetch", (event) => {
+ const { request } = event
+ if (request.method !== "GET") return
+ if (isAPIorEvent(request.url)) return
+
+ // Navigation requests: stale-while-revalidate for instant page loads
+ if (request.mode === "navigate") {
+ event.respondWith(
+ caches.open(CACHE_NAME).then((cache) =>
+ cache.match("/index.html").then((cached) => {
+ const fresh = fetch(request)
+ .then((response) => {
+ if (response.ok) cache.put("/index.html", response.clone())
+ return response
+ })
+ .catch(() => cached)
+ return cached || fresh
+ }),
+ ),
+ )
+ return
+ }
+
+ // Hashed assets: cache-first (immutable filenames)
+ if (isHashedAsset(request.url)) {
+ event.respondWith(
+ caches.open(CACHE_NAME).then((cache) =>
+ cache.match(request).then(
+ (cached) =>
+ cached ||
+ fetch(request).then((response) => {
+ if (response.ok) cache.put(request, response.clone())
+ return response
+ }),
+ ),
+ ),
+ )
+ return
+ }
+
+ // Other static assets: stale-while-revalidate
+ if (isStaticAsset(request.url)) {
+ event.respondWith(
+ caches.open(CACHE_NAME).then((cache) =>
+ cache.match(request).then((cached) => {
+ const fresh = fetch(request)
+ .then((response) => {
+ if (response.ok) cache.put(request, response.clone())
+ return response
+ })
+ .catch(() => cached)
+ return cached || fresh
+ }),
+ ),
+ )
+ return
+ }
+})
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
index 5115f0348ad4..55901a68d457 100644
--- a/packages/app/src/entry.tsx
+++ b/packages/app/src/entry.tsx
@@ -153,6 +153,10 @@ if (import.meta.env.VITE_SENTRY_DSN) {
})
}
+if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.register("/sw.js").catch(() => {})
+}
+
if (root instanceof HTMLElement) {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
diff --git a/packages/app/src/index.css b/packages/app/src/index.css
index 8db576dd8342..b30d41279d20 100644
--- a/packages/app/src/index.css
+++ b/packages/app/src/index.css
@@ -5,6 +5,7 @@
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
font-weight: normal;
font-style: normal;
+ font-display: swap;
}
@layer components {