Multi-adapter mobile UI for AI coding assistants. Supports Claude Code, Codex CLI, and Gemini CLI through one interface. Features: - Real-time bidirectional sync via tmux + WebSocket - Cross-AI review (send one AI's output to another for review) - Multi-review tabs with minimize/expand - Push notifications (PWA) with smart session-aware filtering - Three-channel event system (hooks, file watcher, pane monitor) - Voice input, image paste, draft persistence - Terminal-native design (JetBrains Mono, dark theme, pixel art claw) - CLI with --adapter flag on every command - Zero-overhead fire-and-forget hooks
17 KiB
PWA Optimization Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Bring CodeTap's PWA to production-grade quality with proper viewport handling, splash screens, install prompts, SW updates, badge management, draft persistence, navigation history, and social meta tags.
Architecture: All changes are additive — native Web APIs with feature detection, no new dependencies. App.tsx gains PWA lifecycle effects. ShimmerInput gains draft persistence. Manifest and HTML get richer metadata.
Tech Stack: Vite + vite-plugin-pwa + native Web APIs (beforeinstallprompt, History API, Network Information API)
Spec: docs/superpowers/specs/2026-03-26-pwa-optimization-design.md
File Structure
| File | Responsibility | Action |
|---|---|---|
index.html |
Viewport, splash images, OG tags | Modify |
vite.config.ts |
Manifest shortcuts, screenshots | Modify |
src/App.tsx |
Install prompt, SW update, badge clear, history API | Modify |
src/components/SessionsView.tsx |
Install banner UI | Modify |
src/components/ShimmerInput.tsx |
Draft auto-save | Modify |
src/components/StatusBar.tsx |
Slow network indicator | Modify |
src/index.css |
Safe area utilities | Modify |
public/splash/ |
iOS splash screen images | Create |
public/screenshots/ |
Manifest screenshots | Create |
Task 1: Viewport & Safe Areas
Files:
-
Modify:
index.html -
Modify:
src/index.css -
Step 1: Add viewport-fit=cover to index.html
Change the viewport meta tag:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
- Step 2: Add safe area utilities to index.css
After the existing .safe-bottom rule:
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-x {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
-
Step 3: Verify — open in iOS simulator or DevTools, check notch area
-
Step 4: Commit
git add index.html src/index.css
git commit -m "feat(pwa): viewport-fit=cover and full safe area utilities"
Task 2: iOS Splash Screens
Files:
-
Create:
public/splash/directory -
Modify:
index.html -
Step 1: Create splash screen SVG generator script
Create a simple inline SVG splash as a data URI approach in index.html. This avoids needing to generate multiple PNGs. Use apple-mobile-web-app-startup-image with media queries for major iPhone sizes.
Add to <head> in index.html, after the apple-touch-icon link:
<!-- iOS Splash Screens -->
<meta name="apple-mobile-web-app-title" content="CodeTap" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1290x2796.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1179x2556.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1284x2778.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1170x2532.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1125x2436.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1242x2688.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)"
href="/splash/splash-750x1334.png" />
- Step 2: Generate splash screen PNGs via Node.js canvas script
Create scripts/generate-splash.mjs that uses the built-in node:canvas or a simple HTML-to-PNG approach. Simplest method: create a single-use Node script that writes minimal HTML to a temp file and uses sharp or pure SVG-to-PNG. Actually, the most practical approach for a CLI tool: use a simple Node script that generates SVG strings and writes them as .svg files that Safari can use (Safari accepts SVG for startup images). If SVG doesn't work, use a single high-res PNG and reference it without media queries as a universal fallback.
Practical fallback: Create public/splash/ with a single splash.svg (dark bg + centered "CodeTap" text), referenced without media queries. Remove per-device media queries from Step 1 and use a single universal link tag instead:
<link rel="apple-touch-startup-image" href="/splash/splash.svg" />
If SVG is not supported by Safari for startup images (it isn't), generate a single 1290x2796 PNG using a canvas script at build time, or manually create one. The key requirement is: dark background (#09090b), centered CodeTap text or mascot.
-
Step 3: Verify — add to home screen on iOS, check splash appears
-
Step 4: Commit
git add -f public/splash/ index.html
git commit -m "feat(pwa): iOS splash screens for major iPhone sizes"
Task 3: Android Install Prompt
Files:
-
Modify:
src/App.tsx -
Modify:
src/components/SessionsView.tsx -
Step 1: Add install prompt state to App.tsx
Add state and effect near the top of App():
const [installPrompt, setInstallPrompt] = useState<any>(null);
const [installDismissed, setInstallDismissed] = useState(
() => localStorage.getItem('codetap:install-dismissed') === 'true'
);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setInstallPrompt(e);
};
window.addEventListener('beforeinstallprompt', handler);
const installedHandler = () => {
setInstallPrompt(null);
setInstallDismissed(true);
localStorage.setItem('codetap:install-dismissed', 'true');
};
window.addEventListener('appinstalled', installedHandler);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
window.removeEventListener('appinstalled', installedHandler);
};
}, []);
- Step 2: Pass install props to SessionsView
Update the SessionsView rendering in App.tsx:
<SessionsView
onOpenChat={openChat}
onLogout={handleLogout}
onOpenSettings={() => setView({ name: 'settings' })}
installPrompt={!installDismissed ? installPrompt : null}
onInstall={async () => {
if (installPrompt) {
installPrompt.prompt();
const result = await installPrompt.userChoice;
if (result.outcome === 'accepted') {
setInstallPrompt(null);
setInstallDismissed(true);
localStorage.setItem('codetap:install-dismissed', 'true');
}
}
}}
onDismissInstall={() => {
setInstallDismissed(true);
localStorage.setItem('codetap:install-dismissed', 'true');
}}
/>
- Step 3: Add install banner to SessionsView
Add to SessionsView props interface and render a banner below the header when installPrompt is truthy:
// Add to props
installPrompt?: any;
onInstall?: () => void;
onDismissInstall?: () => void;
// Render below the header, before the tab bar
{installPrompt && (
<div className="flex items-center gap-3 px-4 py-2.5 bg-accent/10 border-b border-accent/20">
<span className="text-xs text-text flex-1 font-mono">Install CodeTap for a better experience</span>
<button onClick={onInstall} className="text-xs font-medium text-accent hover:text-accent-light">Install</button>
<button onClick={onDismissInstall} className="text-xs text-text-dim hover:text-text">Dismiss</button>
</div>
)}
-
Step 4: Verify — open in Chrome Android (or DevTools Application panel), check install banner appears
-
Step 5: Commit
git add src/App.tsx src/components/SessionsView.tsx
git commit -m "feat(pwa): Android install prompt with dismissible banner"
Task 4: Service Worker Update Notification
Files:
-
Modify:
src/App.tsx -
Step 1: Add SW update detection and toast state
Add near other effects in App():
const [swUpdateAvailable, setSwUpdateAvailable] = useState(false);
useEffect(() => {
const handleControllerChange = () => setSwUpdateAvailable(true);
navigator.serviceWorker?.addEventListener('controllerchange', handleControllerChange);
return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange);
}, []);
- Step 2: Render update toast
Add before the closing </div> or at the bottom of the main render:
{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">
<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"
>Refresh</button>
<button
onClick={() => setSwUpdateAvailable(false)}
className="text-sm text-text-dim hover:text-text"
>Later</button>
</div>
</div>
)}
-
Step 3: Verify — modify SW file, rebuild, check toast appears
-
Step 4: Commit
git add src/App.tsx
git commit -m "feat(pwa): service worker update notification toast"
Task 5: Badge Clear on Focus
Files:
-
Modify:
src/App.tsx -
Step 1: Add visibility change listener
Add with other effects in App():
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === 'visible') {
navigator.clearAppBadge?.();
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
- Step 2: Commit
git add src/App.tsx
git commit -m "feat(pwa): clear app badge when app becomes visible"
Task 6: Manifest Shortcuts & Screenshots
Files:
-
Modify:
vite.config.ts -
Create:
public/screenshots/(placeholder) -
Step 1: Add shortcuts to manifest in vite.config.ts
Add after the icons array in the manifest config:
shortcuts: [
{
name: 'New Chat',
short_name: 'New',
url: '/?action=newchat',
icons: [{ src: '/pwa-192x192.png', sizes: '192x192' }],
},
],
categories: ['developer-tools', 'productivity'],
- Step 2: Handle ?action=newchat in App.tsx
Add after the existing ?session= URL handler:
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('action') === 'newchat' && authed) {
window.history.replaceState({}, '', '/');
// Shortcut just brings user to sessions view — they pick a project from there
setView({ name: 'sessions' });
}
}, [authed]);
- Step 3: Add screenshots placeholder to manifest
Add to manifest in vite.config.ts:
screenshots: [
{
src: '/screenshots/narrow.png',
sizes: '1080x1920',
type: 'image/png',
form_factor: 'narrow',
label: 'CodeTap Chat View',
},
{
src: '/screenshots/wide.png',
sizes: '1920x1080',
type: 'image/png',
form_factor: 'wide',
label: 'CodeTap Sessions View',
},
],
Create public/screenshots/ directory with placeholder images (can be actual screenshots later).
- Step 4: Commit
git add vite.config.ts src/App.tsx
git add -f public/screenshots/ 2>/dev/null || true
git commit -m "feat(pwa): manifest shortcuts, categories, and screenshots config"
Task 7: Input Draft Auto-Save
Files:
-
Modify:
src/components/ShimmerInput.tsx -
Step 1: Add draft persistence to ShimmerInput
ShimmerInput doesn't receive a sessionId prop, so use a global draft key. Add after the existing state declarations:
const DRAFT_KEY = 'codetap:draft';
// Restore draft on mount
useEffect(() => {
if (!initialText) {
const saved = localStorage.getItem(DRAFT_KEY);
if (saved) setText(saved);
}
}, []);
// Debounce-save draft on text change
const saveTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
clearTimeout(saveTimer.current);
if (text.trim()) {
saveTimer.current = setTimeout(() => {
localStorage.setItem(DRAFT_KEY, text);
}, 500);
} else {
localStorage.removeItem(DRAFT_KEY);
}
return () => clearTimeout(saveTimer.current);
}, [text]);
- Step 2: Clear draft on send
In the existing send handler, add localStorage.removeItem(DRAFT_KEY) after onSend(...):
// Find the handleSend function and add after onSend call:
localStorage.removeItem(DRAFT_KEY);
-
Step 3: Verify — type text, close tab, reopen, check draft restored
-
Step 4: Commit
git add src/components/ShimmerInput.tsx
git commit -m "feat(pwa): auto-save input draft to localStorage with debounce"
Task 8: Slow Network Detection
Files:
-
Modify:
src/components/StatusBar.tsx -
Step 1: Add network quality detection
Add a hook at the top of StatusBar component:
const [slowNetwork, setSlowNetwork] = useState(false);
useEffect(() => {
const conn = (navigator as any).connection;
if (!conn) return;
const check = () => {
setSlowNetwork(conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g');
};
check();
conn.addEventListener('change', check);
return () => conn.removeEventListener('change', check);
}, []);
- Step 2: Render slow network indicator
Add inside the status bar, before or after the model display:
{slowNetwork && (
<span className="text-warning text-[10px] font-mono">Slow</span>
)}
- Step 3: Commit
git add src/components/StatusBar.tsx
git commit -m "feat(pwa): slow network indicator via Network Information API"
Task 9: History API Navigation
Files:
-
Modify:
src/App.tsx -
Step 1: Push state on view changes
Modify the saveView function to also push history state:
function saveView(view: View) {
sessionStorage.setItem('currentView', JSON.stringify(view));
const url = view.name === 'chat' && view.sessionId
? `/?view=chat&session=${view.sessionId}`
: view.name === 'settings'
? '/?view=settings'
: '/';
window.history.pushState({ view }, '', url);
}
- Step 2: Listen for popstate (back button/gesture)
Add effect in App():
useEffect(() => {
const handlePopState = (event: PopStateEvent) => {
if (event.state?.view) {
setView(event.state.view);
sessionStorage.setItem('currentView', JSON.stringify(event.state.view));
} else {
setView({ name: 'sessions' });
sessionStorage.setItem('currentView', JSON.stringify({ name: 'sessions' }));
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
- Step 3: Use replaceState for initial load (avoid double entry)
In the existing loadView() function, after loading the view, replace the current history entry:
// At the end of App() initialization, after first render:
useEffect(() => {
window.history.replaceState({ view }, '', window.location.pathname + window.location.search);
}, []); // only on mount
-
Step 4: Verify — navigate sessions → chat → back gesture returns to sessions
-
Step 5: Commit
git add src/App.tsx
git commit -m "feat(pwa): History API navigation for back gesture support"
Task 10: OpenGraph Meta Tags
Files:
-
Modify:
index.html -
Step 1: Add OG and Twitter meta tags to index.html
Add before </head>:
<!-- OpenGraph -->
<meta property="og:title" content="CodeTap" />
<meta property="og:description" content="Use Claude Code from your phone. Real-time mobile UI synced with your desktop terminal." />
<meta property="og:type" content="website" />
<meta property="og:image" content="/pwa-512x512.png" />
<meta name="twitter:card" content="summary" />
<meta name="description" content="Use Claude Code from your phone. Real-time mobile UI synced with your desktop terminal." />
- Step 2: Commit
git add index.html
git commit -m "feat(pwa): OpenGraph and Twitter Card meta tags"
Verification
- Run
npm run devin the worktree - Open in Chrome DevTools → Application panel:
- Check manifest loads correctly with shortcuts, screenshots, categories
- Check service worker is registered
- Mobile testing (iOS):
- Add to Home Screen → check splash screen appears
- Verify content extends properly behind notch (viewport-fit=cover)
- Test back gesture navigates correctly
- Mobile testing (Android / Chrome):
- Check install banner appears in SessionsView
- Dismiss and verify it doesn't reappear
- Install and verify banner disappears
- Draft persistence:
- Type text in input, close tab, reopen → text should be restored
- Send message → draft should be cleared
- Badge:
- Receive push notification with badge → switch to app → badge clears
- Network:
- Throttle to 2G in DevTools → "Slow" indicator appears in StatusBar