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:
Binary file not shown.
|
After Width: | Height: | Size: 434 B |
+24
-17
@@ -231,40 +231,53 @@ 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">
|
<div className="min-h-screen bg-bg flex items-center justify-center">
|
||||||
<LoadingAnimation size="lg" label="Connecting..." />
|
<LoadingAnimation size="lg" label="Connecting..." />
|
||||||
</div>
|
</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
|
<ChatView
|
||||||
sessionId={view.sessionId}
|
sessionId={view.sessionId}
|
||||||
cwd={view.cwd}
|
cwd={view.cwd}
|
||||||
@@ -272,6 +285,8 @@ export function App() {
|
|||||||
adapter={view.adapter}
|
adapter={view.adapter}
|
||||||
onBack={backToSessions}
|
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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user