fix(pwa): audit fixes — safe-area, SW lifecycle, badge, tap highlight, update banner, precache

- Add safe-top to all full-screen overlays (PlanMode, DiffViewer, ChatView PlanViewer)
- Add safe-top to SessionsView drill-down header + swipe-back via pushState
- Move safe-top to ChatView outer container (persists when header hides)
- Add skipWaiting + clients.claim for immediate SW updates
- Create monochrome 96x96 badge icon for Android notifications
- Add -webkit-tap-highlight-color: transparent for dark theme
- Show SW update banner on all views, not just SessionsView
- Fix precache duplicates with specific glob patterns (18→16 entries)
- Add safe-bottom to ChatView saveToast
- Fix stale poll interval comment (10s→3s)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuannnn
2026-03-28 04:47:53 +08:00
parent 9c2158961c
commit 299649738e
10 changed files with 67 additions and 36 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

+34 -27
View File
@@ -231,47 +231,62 @@ export function App() {
const isOffline = !deviceOnline || serverOnline === false; const isOffline = !deviceOnline || serverOnline === false;
const updateBanner = swUpdateAvailable && (
<div className="fixed bottom-6 left-4 right-4 bg-surface border border-accent/30 rounded-md px-4 py-3 flex items-center justify-between z-50 shadow-lg safe-bottom">
<span className="text-sm text-text font-mono">New version available</span>
<div className="flex gap-2">
<button onClick={() => window.location.reload()} className="text-sm font-medium text-accent hover:text-accent-light cursor-pointer">Refresh</button>
<button onClick={() => setSwUpdateAvailable(false)} className="text-sm text-text-dim hover:text-text cursor-pointer">Later</button>
</div>
</div>
);
// Splash screen while first health check is pending // Splash screen while first health check is pending
if (serverOnline === null) { if (serverOnline === null) {
return ( return (
<div className="min-h-screen bg-bg flex items-center justify-center"> <>
<LoadingAnimation size="lg" label="Connecting..." /> <div className="min-h-screen bg-bg flex items-center justify-center">
</div> <LoadingAnimation size="lg" label="Connecting..." />
</div>
{updateBanner}
</>
); );
} }
// Offline screen // Offline screen
if (isOffline) { if (isOffline) {
return <OfflineView onRetry={checkHealth} />; return <><OfflineView onRetry={checkHealth} />{updateBanner}</>;
} }
if (!authed) { if (!authed) {
return <LoginView onLogin={handleLogin} />; return <><LoginView onLogin={handleLogin} />{updateBanner}</>;
} }
if (view.name === 'newchat') { if (view.name === 'newchat') {
return ( return (
<NewChatView <>
cwd={view.cwd} <NewChatView cwd={view.cwd} onStartChat={startChat} onBack={backToSessions} />
onStartChat={startChat} {updateBanner}
onBack={backToSessions} </>
/>
); );
} }
if (view.name === 'settings') { if (view.name === 'settings') {
return <SettingsView onBack={() => setView({ name: 'sessions' })} />; return <><SettingsView onBack={() => setView({ name: 'sessions' })} />{updateBanner}</>;
} }
if (view.name === 'chat') { if (view.name === 'chat') {
return ( return (
<ChatView <>
sessionId={view.sessionId} <ChatView
cwd={view.cwd} sessionId={view.sessionId}
initialPrompt={view.initialPrompt} cwd={view.cwd}
adapter={view.adapter} initialPrompt={view.initialPrompt}
onBack={backToSessions} adapter={view.adapter}
/> onBack={backToSessions}
/>
{updateBanner}
</>
); );
} }
@@ -285,15 +300,7 @@ export function App() {
onInstall={handleInstall} onInstall={handleInstall}
onDismissInstall={dismissInstall} onDismissInstall={dismissInstall}
/> />
{swUpdateAvailable && ( {updateBanner}
<div className="fixed bottom-6 left-4 right-4 bg-surface border border-accent/30 rounded-md px-4 py-3 flex items-center justify-between z-50 shadow-lg">
<span className="text-sm text-text font-mono">New version available</span>
<div className="flex gap-2">
<button onClick={() => window.location.reload()} className="text-sm font-medium text-accent hover:text-accent-light cursor-pointer">Refresh</button>
<button onClick={() => setSwUpdateAvailable(false)} className="text-sm text-text-dim hover:text-text cursor-pointer">Later</button>
</div>
</div>
)}
</> </>
); );
} }
+2 -2
View File
@@ -27,7 +27,7 @@ function PlanViewer({ plan }: { plan: string }) {
if (expanded) { if (expanded) {
return ( return (
<div className="fixed inset-0 bg-bg z-50 flex flex-col"> <div className="fixed inset-0 bg-bg z-50 flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0"> <div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 safe-top">
<Badge>PLAN</Badge> <Badge>PLAN</Badge>
<Button variant="ghost" size="icon" onClick={() => setExpanded(false)}> <Button variant="ghost" size="icon" onClick={() => setExpanded(false)}>
<X className="w-5 h-5" /> <X className="w-5 h-5" />
@@ -567,7 +567,7 @@ export function ChatView({
{/* Save-as-instruction toast */} {/* Save-as-instruction toast */}
{saveToast && ( {saveToast && (
<div className="fixed bottom-20 left-4 right-4 bg-surface border border-border rounded-xl p-3 flex items-center justify-between z-30"> <div className="fixed bottom-20 left-4 right-4 bg-surface border border-border rounded-xl p-3 flex items-center justify-between z-30 safe-bottom">
<span className="text-sm text-text-dim"></span> <span className="text-sm text-text-dim"></span>
<button <button
className="text-sm text-accent font-medium px-3 py-1 rounded-md hover:bg-accent/10" className="text-sm text-accent font-medium px-3 py-1 rounded-md hover:bg-accent/10"
+1 -1
View File
@@ -8,7 +8,7 @@ export function DiffViewer({ filePath, oldString, newString, onClose }: {
const newLines = newString.split('\n'); const newLines = newString.split('\n');
return ( return (
<div className="fixed inset-0 bg-bg z-50 flex flex-col"> <div className="fixed inset-0 bg-bg z-50 flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0"> <div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 safe-top">
<div className="flex items-center gap-3 overflow-hidden"> <div className="flex items-center gap-3 overflow-hidden">
<Button variant="ghost" size="icon" onClick={onClose}> <Button variant="ghost" size="icon" onClick={onClose}>
<X className="size-4" /> <X className="size-4" />
+1 -1
View File
@@ -55,7 +55,7 @@ export function PlanMode({ input, onApprove, onApproveYolo, onReject, onSendFeed
if (showFull) { if (showFull) {
return ( return (
<div className="fixed inset-0 bg-bg z-50 flex flex-col"> <div className="fixed inset-0 bg-bg z-50 flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0"> <div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 safe-top">
<Badge className="font-mono">PLAN</Badge> <Badge className="font-mono">PLAN</Badge>
<Button variant="ghost" size="icon" onClick={() => setShowFull(false)}> <Button variant="ghost" size="icon" onClick={() => setShowFull(false)}>
<X className="size-4" /> <X className="size-4" />
+1 -1
View File
@@ -132,7 +132,7 @@ export function SessionsView({
if (selectedProjectDir) { if (selectedProjectDir) {
return ( return (
<div className="min-h-screen bg-bg flex flex-col"> <div className="min-h-screen bg-bg flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0"> <div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 safe-top">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Button <Button
variant="ghost" variant="ghost"
+19 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { STORAGE } from '../lib/storage-keys'; import { STORAGE } from '../lib/storage-keys';
@@ -39,7 +39,7 @@ export function useSessions() {
} }
}, []); }, []);
// Poll every 10s when Active tab is selected // Poll every 3s when Active tab is selected
useEffect(() => { useEffect(() => {
if (activeTab !== 'active') return; if (activeTab !== 'active') return;
fetchActiveSessions(); fetchActiveSessions();
@@ -86,11 +86,28 @@ export function useSessions() {
setSelectedProjectDir(dir); setSelectedProjectDir(dir);
if (dir) { if (dir) {
localStorage.setItem(STORAGE.PROJECT_DIR, dir); localStorage.setItem(STORAGE.PROJECT_DIR, dir);
// Push history so back navigation (swipe-back, back button) returns to project list
window.history.pushState({ selectProject: dir }, '', '/');
} else { } else {
localStorage.removeItem(STORAGE.PROJECT_DIR); localStorage.removeItem(STORAGE.PROJECT_DIR);
} }
}, []); }, []);
// Clear project selection on back navigation (swipe-back, back button)
const selectedProjectDirRef = useRef(selectedProjectDir);
selectedProjectDirRef.current = selectedProjectDir;
useEffect(() => {
const handlePopState = (e: PopStateEvent) => {
if (!e.state?.selectProject && selectedProjectDirRef.current) {
setSelectedProjectDir(null);
localStorage.removeItem(STORAGE.PROJECT_DIR);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
return { return {
sessions: filteredSessions, sessions: filteredSessions,
projects, projects,
+1
View File
@@ -28,6 +28,7 @@ body {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
overscroll-behavior: none; overscroll-behavior: none;
} }
+7 -1
View File
@@ -8,6 +8,12 @@ declare const self: ServiceWorkerGlobalScope;
// Precache static assets (injected by vite-plugin-pwa at build time) // Precache static assets (injected by vite-plugin-pwa at build time)
precacheAndRoute(self.__WB_MANIFEST); precacheAndRoute(self.__WB_MANIFEST);
// Skip waiting + claim so updates take effect immediately
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
// Cache stable API responses — show last-known data when offline. // Cache stable API responses — show last-known data when offline.
// Exclude volatile real-time endpoints (messages, active sessions, reviews). // Exclude volatile real-time endpoints (messages, active sessions, reviews).
registerRoute( registerRoute(
@@ -52,7 +58,7 @@ self.addEventListener('push', (event) => {
return self.registration.showNotification(payload.title, { return self.registration.showNotification(payload.title, {
body: payload.body, body: payload.body,
icon: '/pwa-192x192.png', icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png', badge: '/badge-96x96.png',
tag: payload.tag || payload.data?.sessionId || 'default', tag: payload.tag || payload.data?.sessionId || 'default',
data: payload.data, data: payload.data,
}); });
+1 -1
View File
@@ -39,7 +39,7 @@ export default defineConfig({
categories: ['developer-tools', 'productivity'], categories: ['developer-tools', 'productivity'],
}, },
injectManifest: { injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,svg,woff2}', '*.png', 'mascot/*.png'], globPatterns: ['**/*.{js,css,html,ico,svg,woff2}', 'favicon-*.png', 'apple-touch-icon.png', 'badge-*.png', 'mascot/*.png'],
}, },
}), }),
], ],