chore: release 0.3.1 — iOS PWA fixes, notification improvements, docs update
- fix(pwa): iOS keyboard gap caused by WebKit viewport-fit=cover bug. After keyboard open/close, 100dvh permanently shrinks. Track max innerHeight in --app-height CSS variable as stable replacement. - feat(pwa): auto-prompt notification permission on first login in standalone mode (once only, skips if denied). - refactor: remove duplicate notification toggle from header menu (already in Settings). - feat(dev): expose Vite dev server on network (host: true) for mobile testing via Tailscale. - docs: update README — add Task Progress FAB, fix notification flow description, document OPENAI_API_KEY / VAPID_EMAIL env vars, clarify voice input backends, add CLI --version/--help, update .env.example. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,8 @@
|
|||||||
CLAWTAP_PASSWORD=your-password-here
|
CLAWTAP_PASSWORD=your-password-here
|
||||||
PORT=3456
|
PORT=3456
|
||||||
|
|
||||||
|
# Optional: enables Whisper voice transcription (higher accuracy)
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
|
# Optional: contact email for Web Push VAPID identification
|
||||||
|
# VAPID_EMAIL=you@example.com
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ clawtap
|
|||||||
|
|
||||||
Open the URL on your phone. That's it.
|
Open the URL on your phone. That's it.
|
||||||
|
|
||||||
|
> **Mobile access?** ClawTap needs HTTPS for PWA install and push notifications. The easiest way is [Tailscale](https://tailscale.com): `tailscale serve --bg 3456` gives you a trusted HTTPS URL instantly. See [PWA & Push Notifications](#-pwa--push-notifications) for details.
|
||||||
|
|
||||||
ClawTap auto-detects which AI CLIs you have installed (`claude`, `codex`, `gemini`) and enables them automatically.
|
ClawTap auto-detects which AI CLIs you have installed (`claude`, `codex`, `gemini`) and enables them automatically.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -124,9 +126,16 @@ Every tool call renders as an expandable card:
|
|||||||
|
|
||||||
Send follow-up messages while the AI is still responding. They appear as "Queued" with Edit/Cancel and auto-send when the AI finishes. Paste images from clipboard with thumbnail preview.
|
Send follow-up messages while the AI is still responding. They appear as "Queued" with Edit/Cancel and auto-send when the AI finishes. Paste images from clipboard with thumbnail preview.
|
||||||
|
|
||||||
|
### Task Progress
|
||||||
|
|
||||||
|
When your AI creates tasks (via `TaskCreate`/`TaskUpdate`), a floating progress ring appears in the bottom-right corner showing completion (e.g., 2/5). Tap it to expand a bottom sheet with full task details. Tasks are grouped by rounds — a new round starts when all previous tasks complete. The ring auto-fades 3 seconds after all tasks finish.
|
||||||
|
|
||||||
### Voice Input
|
### Voice Input
|
||||||
|
|
||||||
Tap the mic icon to dictate coding instructions. Uses the Web Speech API with real-time interim transcription. Works in any language.
|
Tap the mic icon to dictate coding instructions. Supports two backends:
|
||||||
|
|
||||||
|
- **Web Speech API** (default) — real-time interim transcription, currently configured for Traditional Chinese (`zh-TW`)
|
||||||
|
- **OpenAI Whisper** — higher accuracy, requires `OPENAI_API_KEY` (see [Configuration](#-configuration))
|
||||||
|
|
||||||
### Smart Input
|
### Smart Input
|
||||||
|
|
||||||
@@ -151,6 +160,8 @@ clawtap hooks install [--adapter claude] # Install hooks (all or one adapter)
|
|||||||
clawtap hooks uninstall [--adapter gemini] # Remove hooks
|
clawtap hooks uninstall [--adapter gemini] # Remove hooks
|
||||||
clawtap cert # Generate HTTPS certificate
|
clawtap cert # Generate HTTPS certificate
|
||||||
clawtap stop # Graceful shutdown
|
clawtap stop # Graceful shutdown
|
||||||
|
clawtap --version # Show version
|
||||||
|
clawtap --help # Show help
|
||||||
```
|
```
|
||||||
|
|
||||||
The `--adapter` flag works with every command. Session lists show colored `[Claude]`/`[Codex]`/`[Gemini]` labels with first-prompt previews.
|
The `--adapter` flag works with every command. Session lists show colored `[Claude]`/`[Codex]`/`[Gemini]` labels with first-prompt previews.
|
||||||
@@ -175,7 +186,7 @@ clawtap cert
|
|||||||
|
|
||||||
**2. Install PWA:** Open the URL in Safari → Share → **Add to Home Screen**.
|
**2. Install PWA:** Open the URL in Safari → Share → **Add to Home Screen**.
|
||||||
|
|
||||||
**3. Enable notifications:** Open ClawTap from home screen → tap the **bell icon** → Allow.
|
**3. Enable notifications:** On first login in standalone mode, ClawTap automatically prompts for notification permission. You can also toggle notifications manually in **Settings**.
|
||||||
|
|
||||||
### Smart Notifications
|
### Smart Notifications
|
||||||
|
|
||||||
@@ -195,6 +206,8 @@ The app icon badge shows how many sessions have unread notifications. Entering a
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CLAWTAP_PASSWORD` | *(required)* | Login password |
|
| `CLAWTAP_PASSWORD` | *(required)* | Login password |
|
||||||
| `PORT` | `3456` | Server port |
|
| `PORT` | `3456` | Server port |
|
||||||
|
| `OPENAI_API_KEY` | *(optional)* | Enables Whisper voice transcription (higher accuracy than Web Speech API) |
|
||||||
|
| `VAPID_EMAIL` | `noreply@clawtap.local` | Contact email for Web Push VAPID identification |
|
||||||
|
|
||||||
HTTPS is enabled automatically when `~/.clawtap/cert.pem` and `~/.clawtap/key.pem` exist. Otherwise the server runs on HTTP. Tailscale Serve is the easiest path to HTTPS.
|
HTTPS is enabled automatically when `~/.clawtap/cert.pem` and `~/.clawtap/key.pem` exist. Otherwise the server runs on HTTP. Tailscale Serve is the easiest path to HTTPS.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuannnn/clawtap",
|
"name": "@kuannnn/clawtap",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"description": "Mobile UI for AI coding assistants. Real-time sync with Claude Code, Codex CLI, and Gemini CLI via tmux.",
|
"description": "Mobile UI for AI coding assistants. Real-time sync with Claude Code, Codex CLI, and Gemini CLI via tmux.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+39
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { STORAGE } from './lib/storage-keys';
|
import { STORAGE } from './lib/storage-keys';
|
||||||
import { isAuthenticated, clearToken } from './lib/api';
|
import { isAuthenticated, clearToken } from './lib/api';
|
||||||
|
import { usePushNotifications } from './hooks/usePushNotifications';
|
||||||
import { LoginView } from './components/LoginView';
|
import { LoginView } from './components/LoginView';
|
||||||
import { SessionsView } from './components/SessionsView';
|
import { SessionsView } from './components/SessionsView';
|
||||||
import { ChatView } from './components/ChatView';
|
import { ChatView } from './components/ChatView';
|
||||||
@@ -59,6 +60,7 @@ export function App() {
|
|||||||
const [deviceOnline, setDeviceOnline] = useState(navigator.onLine);
|
const [deviceOnline, setDeviceOnline] = useState(navigator.onLine);
|
||||||
const consecutiveFails = useRef(0);
|
const consecutiveFails = useRef(0);
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
|
const { supported: pushSupported, subscribed: pushSubscribed, subscribe: pushSubscribe } = usePushNotifications();
|
||||||
|
|
||||||
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
const [installDismissed, setInstallDismissed] = useState(
|
const [installDismissed, setInstallDismissed] = useState(
|
||||||
@@ -120,6 +122,43 @@ export function App() {
|
|||||||
return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange);
|
return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// PWA iOS: Sync --app-height CSS variable to the true viewport height.
|
||||||
|
// WebKit bug: after a keyboard open/close cycle, viewport-fit=cover's layout
|
||||||
|
// extension is lost, causing 100dvh to shrink permanently. We track the max
|
||||||
|
// observed innerHeight and lock it as the app height.
|
||||||
|
useEffect(() => {
|
||||||
|
let maxHeight = window.innerHeight;
|
||||||
|
const sync = () => {
|
||||||
|
const h = window.innerHeight;
|
||||||
|
if (h > maxHeight) maxHeight = h;
|
||||||
|
document.documentElement.style.setProperty('--app-height', `${maxHeight}px`);
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
window.visualViewport?.addEventListener('resize', sync);
|
||||||
|
window.addEventListener('resize', sync);
|
||||||
|
return () => {
|
||||||
|
window.visualViewport?.removeEventListener('resize', sync);
|
||||||
|
window.removeEventListener('resize', sync);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// PWA: Auto-prompt notification permission on first login in standalone mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authed || !pushSupported || pushSubscribed) return;
|
||||||
|
if (localStorage.getItem(STORAGE.PUSH_PROMPTED)) return;
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||||
|
|| (navigator as any).standalone === true;
|
||||||
|
if (!isStandalone) return;
|
||||||
|
if (typeof Notification !== 'undefined' && Notification.permission === 'denied') return;
|
||||||
|
|
||||||
|
// Small delay so the UI has time to settle after login
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
localStorage.setItem(STORAGE.PUSH_PROMPTED, '1');
|
||||||
|
pushSubscribe().catch(() => {});
|
||||||
|
}, 1500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [authed, pushSupported, pushSubscribed, pushSubscribe]);
|
||||||
|
|
||||||
// PWA: Clear app badge on focus
|
// PWA: Clear app badge on focus
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleVisibility = () => {
|
const handleVisibility = () => {
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ export function ChatView({
|
|||||||
|
|
||||||
if (!selectedAdapter) {
|
if (!selectedAdapter) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh bg-bg items-center justify-center">
|
<div className="flex flex-col bg-bg items-center justify-center" style={{ height: 'var(--app-height, 100dvh)' }}>
|
||||||
<LoadingAnimation size="md" label="Connecting..." />
|
<LoadingAnimation size="md" label="Connecting..." />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -498,7 +498,8 @@ export function ChatView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col h-dvh bg-bg relative overflow-hidden safe-top"
|
className="flex flex-col bg-bg relative overflow-hidden safe-top"
|
||||||
|
style={{ height: 'var(--app-height, 100dvh)' }}
|
||||||
>
|
>
|
||||||
{/* Header — overlays content, slides up when scrolling */}
|
{/* Header — overlays content, slides up when scrolling */}
|
||||||
<div className={`absolute top-0 left-0 right-0 flex items-center gap-2 px-4 py-3 border-b border-border bg-bg z-10 safe-top transition-transform duration-200 ${headerHidden ? '-translate-y-full' : 'translate-y-0'}`}>
|
<div className={`absolute top-0 left-0 right-0 flex items-center gap-2 px-4 py-3 border-b border-border bg-bg z-10 safe-top transition-transform duration-200 ${headerHidden ? '-translate-y-full' : 'translate-y-0'}`}>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { AdapterTabs } from './AdapterTabs';
|
|||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { LoadingAnimation } from './ui/LoadingAnimation';
|
import { LoadingAnimation } from './ui/LoadingAnimation';
|
||||||
import { ChevronLeft, ChevronRight, Plus, RefreshCw, Bell, BellOff, ArrowRightLeft, ClipboardList, MoreVertical } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Plus, RefreshCw, ArrowRightLeft, ClipboardList, MoreVertical } from 'lucide-react';
|
||||||
import { timeAgo, dirName, PERMISSION_MODES } from '@/lib/utils';
|
import { timeAgo, dirName, PERMISSION_MODES } from '@/lib/utils';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { getBrand, ADAPTER_BRANDS } from '@/lib/adapter-brands';
|
import { getBrand, ADAPTER_BRANDS } from '@/lib/adapter-brands';
|
||||||
@@ -41,7 +41,7 @@ export function SessionsView({
|
|||||||
activeSessionIds,
|
activeSessionIds,
|
||||||
refreshActive,
|
refreshActive,
|
||||||
} = useSessions();
|
} = useSessions();
|
||||||
const { supported: pushSupported, subscribed, subscribe, unsubscribe } = usePushNotifications();
|
const { subscribed } = usePushNotifications();
|
||||||
const [showBrowser, setShowBrowser] = useState(false);
|
const [showBrowser, setShowBrowser] = useState(false);
|
||||||
const [showHeaderMenu, setShowHeaderMenu] = useState(false);
|
const [showHeaderMenu, setShowHeaderMenu] = useState(false);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
@@ -256,15 +256,6 @@ export function SessionsView({
|
|||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setShowHeaderMenu(false)} />
|
<div className="fixed inset-0 z-40" onClick={() => setShowHeaderMenu(false)} />
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 bg-surface border border-border rounded-lg py-1 min-w-[180px] shadow-lg">
|
<div className="absolute right-0 top-full mt-1 z-50 bg-surface border border-border rounded-lg py-1 min-w-[180px] shadow-lg">
|
||||||
{pushSupported && (
|
|
||||||
<button
|
|
||||||
onClick={() => { (subscribed ? unsubscribe() : subscribe()).catch(() => {}); setShowHeaderMenu(false); }}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-mono text-text-dim hover:text-text hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
{subscribed ? <Bell className="w-3.5 h-3.5" /> : <BellOff className="w-3.5 h-3.5" />}
|
|
||||||
{subscribed ? 'Disable notifications' : 'Enable notifications'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { onOpenSettings(); setShowHeaderMenu(false); }}
|
onClick={() => { onOpenSettings(); setShowHeaderMenu(false); }}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-mono text-text-dim hover:text-text hover:bg-white/5 transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-mono text-text-dim hover:text-text hover:bg-white/5 transition-colors"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const STORAGE = {
|
|||||||
DRAFT: 'clawtap:draft',
|
DRAFT: 'clawtap:draft',
|
||||||
INSTALL_DISMISSED: 'clawtap:install-dismissed',
|
INSTALL_DISMISSED: 'clawtap:install-dismissed',
|
||||||
SESSIONS_TAB: 'clawtap:sessionsTab',
|
SESSIONS_TAB: 'clawtap:sessionsTab',
|
||||||
|
PUSH_PROMPTED: 'clawtap:push-prompted',
|
||||||
adapterPrefs: (id: string) => `clawtap:adapterPrefs:${id}` as const,
|
adapterPrefs: (id: string) => `clawtap:adapterPrefs:${id}` as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'https://localhost:3456',
|
target: 'https://localhost:3456',
|
||||||
|
|||||||
Reference in New Issue
Block a user