Compare commits
10 Commits
9c2158961c
...
4e6dfb4726
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e6dfb4726 | |||
| fc0527e9e7 | |||
| 35b4519b94 | |||
| a1079766bd | |||
| e2bf3512c9 | |||
| 10c38ad2e4 | |||
| b4d55c4de3 | |||
| a1ada37cba | |||
| fa81cb175c | |||
| 299649738e |
@@ -1,2 +1,8 @@
|
||||
CLAWTAP_PASSWORD=your-password-here
|
||||
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
|
||||
|
||||
@@ -9,3 +9,5 @@ tests/screenshots/
|
||||
package-lock.json
|
||||
docs/
|
||||
.server.pid
|
||||
|
||||
.bkit/
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development (client + server with hot reload)
|
||||
npm run dev
|
||||
|
||||
# Server only
|
||||
npm run dev:server
|
||||
|
||||
# Client only
|
||||
npm run dev:client
|
||||
|
||||
# Production build (frontend → dist/)
|
||||
npm run build
|
||||
|
||||
# Run server against built dist/
|
||||
npm start
|
||||
```
|
||||
|
||||
No test runner is configured. Tests in `test/mvp/` and `tests/` are standalone Node.js scripts run directly:
|
||||
```bash
|
||||
node test/mvp/test-e2e.js
|
||||
node test/mvp/test-hooks.js
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Required: `CLAWTAP_PASSWORD=<password>`
|
||||
Optional: `PORT` (default 3456), `OPENAI_API_KEY` (enables voice transcription)
|
||||
|
||||
State stored in `~/.clawtap/` — SQLite DB, VAPID keys, push subscriptions, PID file.
|
||||
|
||||
In production, the server runs as a systemd user service (`~/.config/systemd/user/clawtap.service`) with env vars loaded from `~/.clawtap/env`; use `./update-service.sh` (not `npm install -g .`) to build and redeploy.
|
||||
|
||||
## Architecture
|
||||
|
||||
ClawTap bridges mobile browsers to AI CLI tools running in tmux on the dev machine.
|
||||
|
||||
```
|
||||
Mobile PWA (React) ◄─ WebSocket ─► Express Server ◄─ tmux sendKeys ─► claude/codex/gemini CLI
|
||||
```
|
||||
|
||||
### Server (`server/`)
|
||||
|
||||
- **`index.ts`** — Express app, HTTP/HTTPS server, REST routes, WS transport setup, graceful shutdown
|
||||
- **`session-manager.ts`** — Bridges adapter events to WebSocket clients. Transport-agnostic. Routes all adapter events (streaming-text, tool-start/done, permission-request, etc.) to connected clients. Manages push notifications when no client is watching.
|
||||
- **`adapters/`** — Plugin system for CLI backends
|
||||
- **`interface.ts`** (`IAdapter`) — Abstract base class all adapters extend. Emits standardized events; see JSDoc for full event list.
|
||||
- **`registry.ts`** — Registers adapters; `DEFAULT_ADAPTER` is `'claude'`
|
||||
- **`shared/tmux-manager.ts`** — Core tmux primitive: create windows, send keys, paste buffers (>500 chars or multiline use paste buffer instead of sendKeys)
|
||||
- **`claude/`**, **`codex/`**, **`gemini/`** — Per-adapter implementations
|
||||
- **`transport/websocket-transport.ts`** — WS server; wraps raw `ws` into `ClientConnection` abstractions
|
||||
- **`db.ts`** — SQLite via `better-sqlite3`; tables: `push_subscriptions`, `login_attempts`, `session_reviews`, `session_stats`, `user_preferences`
|
||||
- **`push.ts`** — Web Push (VAPID) notifications
|
||||
- **`auth.ts`** — Password-based auth with bcrypt + JWT; rate-limits login attempts by IP
|
||||
- **`config.ts`** — Reads env vars; auto-detects HTTPS certs at `~/.clawtap/cert.pem` + `key.pem`
|
||||
- **`stores/task-aggregator.ts`** — Aggregates task-tool events (TodoRead/TodoWrite/Task) into round-based groups for the mobile task panel
|
||||
|
||||
### Frontend (`src/`)
|
||||
|
||||
- **`App.tsx`** — Top-level view router (sessions / newchat / chat / settings). View persisted to `sessionStorage`; URL updated via `history.pushState`.
|
||||
- **`lib/ws.ts`** — WebSocket singleton; reconnect logic
|
||||
- **`lib/api.ts`** — Fetch wrapper with JWT auth header
|
||||
- **`hooks/useChat.ts`** — Main chat hook: sends queries, handles all WS message types, manages message list and tool state
|
||||
- **`hooks/useTaskState.ts`** — Consumes `TASK_STATE` WS events; surfaces round-based task groups to the FAB
|
||||
- **`components/ChatView.tsx`** — Full chat screen with message list, tool cards, interactive prompt overlay, review panel
|
||||
- **`components/FloatingReviewPanel.tsx`** — Multi-tab cross-AI review panel
|
||||
- **`components/adapters/`** — Adapter-specific UI (models, permission modes, branding)
|
||||
|
||||
### WebSocket Protocol
|
||||
|
||||
All message types defined in `server/ws-types.ts` (`WS` const) and `server/types/messages.ts`. Key flows:
|
||||
|
||||
- Client sends `query` → server routes to adapter → adapter emits `streaming-text` / `tool-start` / `tool-done` / `session-idle` → server broadcasts to clients
|
||||
- Adapter emits `permission-request` → server sends `interactive-prompt` to client → client sends `prompt-response` → server calls `respondInteractivePrompt`
|
||||
- Cross-AI review: client calls `POST /api/reviews/register` → server broadcasts `review-started` to parent session clients
|
||||
|
||||
### Adding a New Adapter
|
||||
|
||||
1. Create `server/adapters/<name>/index.ts` extending `IAdapter`
|
||||
2. Set static `id`, `displayName`, `command`
|
||||
3. Implement `setup(app)`, `startSession`, `resumeSession`, `sendMessage`, `interrupt`, `getSessions`, `getMessages`
|
||||
4. Register in `server/adapters/init.ts`
|
||||
5. Add frontend branding in `src/lib/adapter-brands.ts`
|
||||
@@ -41,6 +41,8 @@ clawtap
|
||||
|
||||
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.
|
||||
|
||||
<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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
@@ -151,6 +160,8 @@ clawtap hooks install [--adapter claude] # Install hooks (all or one adapter)
|
||||
clawtap hooks uninstall [--adapter gemini] # Remove hooks
|
||||
clawtap cert # Generate HTTPS certificate
|
||||
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.
|
||||
@@ -175,7 +186,7 @@ clawtap cert
|
||||
|
||||
**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
|
||||
|
||||
@@ -195,6 +206,8 @@ The app icon badge shows how many sessions have unread notifications. Entering a
|
||||
|----------|---------|-------------|
|
||||
| `CLAWTAP_PASSWORD` | *(required)* | Login password |
|
||||
| `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.
|
||||
|
||||
|
||||
+2
-15
@@ -249,22 +249,9 @@ ensure_server() {
|
||||
|
||||
ensure_server
|
||||
|
||||
# Authenticate with the ClawTap server API
|
||||
get_auth_token() {
|
||||
local BODY
|
||||
BODY=$(printf '%s' "$CLAWTAP_PASSWORD" | python3 -c 'import sys,json; print(json.dumps({"password": sys.stdin.read()}))' 2>/dev/null)
|
||||
curl -sk -X POST "${PROTOCOL}://localhost:${PORT}/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" 2>/dev/null | \
|
||||
python3 -c 'import sys,json; print(json.load(sys.stdin).get("token",""))' 2>/dev/null
|
||||
}
|
||||
|
||||
# Localhost requests are trusted by the server — no token needed
|
||||
require_auth() {
|
||||
AUTH_TOKEN=$(get_auth_token)
|
||||
if [ -z "$AUTH_TOKEN" ]; then
|
||||
echo "Error: Failed to authenticate with ClawTap server"
|
||||
exit 1
|
||||
fi
|
||||
AUTH_TOKEN=""
|
||||
}
|
||||
|
||||
# No args → just start server, print URLs, exit
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kuannnn/clawtap",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.1",
|
||||
"description": "Mobile UI for AI coding assistants. Real-time sync with Claude Code, Codex CLI, and Gemini CLI via tmux.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 434 B |
@@ -123,6 +123,8 @@ export class ClaudeAdapter extends IAdapter {
|
||||
hookRoute(`${prefix}/stop`, (body) => {
|
||||
this._tmux.handleStop(body);
|
||||
});
|
||||
// SubagentStop (subagent finishes mid-turn) — no-op, main Stop handles turn end
|
||||
hookRoute(`${prefix}/subagent-stop`, (_body) => {});
|
||||
hookRoute(`${prefix}/permission-request`, (body) => {
|
||||
this._tmux.handlePermissionRequest(body);
|
||||
});
|
||||
|
||||
@@ -68,6 +68,12 @@ export function verifyToken(token: string): Record<string, unknown> | null {
|
||||
}
|
||||
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const remoteAddr = req.socket.remoteAddress;
|
||||
if (remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
|
||||
|
||||
+22
-5
@@ -21,6 +21,7 @@ export function initDB(config: AppConfig): void {
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
device_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
last_used TEXT
|
||||
);
|
||||
@@ -80,6 +81,13 @@ export function initDB(config: AppConfig): void {
|
||||
db.exec("ALTER TABLE session_reviews ADD COLUMN parent_adapter TEXT NOT NULL DEFAULT 'claude'");
|
||||
}
|
||||
|
||||
// Migration: add device_id column to push_subscriptions if missing
|
||||
const pushInfo = db.pragma('table_info(push_subscriptions)') as { name: string }[];
|
||||
if (!pushInfo.some(c => c.name === 'device_id')) {
|
||||
db.exec("ALTER TABLE push_subscriptions ADD COLUMN device_id TEXT DEFAULT NULL");
|
||||
}
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_push_device ON push_subscriptions(device_id)");
|
||||
|
||||
// Migration: add end_anchor_message_id column to session_reviews if missing
|
||||
if (!reviewInfo.some(c => c.name === 'end_anchor_message_id')) {
|
||||
db.exec("ALTER TABLE session_reviews ADD COLUMN end_anchor_message_id TEXT DEFAULT NULL");
|
||||
@@ -109,6 +117,7 @@ function getDB(): BetterSqlite3.Database {
|
||||
|
||||
interface PreparedStatements {
|
||||
pushSubsSave: BetterSqlite3.Statement;
|
||||
pushSubsRemoveByDevice: BetterSqlite3.Statement;
|
||||
pushSubsRemove: BetterSqlite3.Statement;
|
||||
pushSubsGetAll: BetterSqlite3.Statement;
|
||||
pushSubsMarkUsed: BetterSqlite3.Statement;
|
||||
@@ -139,12 +148,16 @@ function stmts(): PreparedStatements {
|
||||
_stmts = {
|
||||
// push_subscriptions
|
||||
pushSubsSave: d.prepare(`
|
||||
INSERT INTO push_subscriptions (endpoint, p256dh, auth)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO push_subscriptions (endpoint, p256dh, auth, device_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth
|
||||
auth = excluded.auth,
|
||||
device_id = excluded.device_id
|
||||
`),
|
||||
pushSubsRemoveByDevice: d.prepare(
|
||||
`DELETE FROM push_subscriptions WHERE device_id = ? AND endpoint != ?`
|
||||
),
|
||||
pushSubsRemove: d.prepare(
|
||||
`DELETE FROM push_subscriptions WHERE endpoint = ?`
|
||||
),
|
||||
@@ -243,13 +256,17 @@ export interface PushSubRow {
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
device_id: string | null;
|
||||
created_at: string;
|
||||
last_used: string | null;
|
||||
}
|
||||
|
||||
export const pushSubs = {
|
||||
save(endpoint: string, p256dh: string, auth: string): void {
|
||||
stmts().pushSubsSave.run(endpoint, p256dh, auth);
|
||||
save(endpoint: string, p256dh: string, auth: string, deviceId: string | null): void {
|
||||
stmts().pushSubsSave.run(endpoint, p256dh, auth, deviceId ?? null);
|
||||
if (deviceId) {
|
||||
stmts().pushSubsRemoveByDevice.run(deviceId, endpoint);
|
||||
}
|
||||
},
|
||||
|
||||
remove(endpoint: string): void {
|
||||
|
||||
+2
-2
@@ -394,9 +394,9 @@ async function start(): Promise<void> {
|
||||
});
|
||||
|
||||
app.post('/api/push/subscribe', authMiddleware, (req: Request, res: Response) => {
|
||||
const { subscription } = req.body as { subscription?: { endpoint?: string } };
|
||||
const { subscription, deviceId } = req.body as { subscription?: { endpoint?: string }; deviceId?: string };
|
||||
if (!subscription?.endpoint) return res.status(400).json({ error: 'Missing subscription' });
|
||||
saveSubscription(subscription as any);
|
||||
saveSubscription(subscription as any, deviceId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
+27
-12
@@ -5,6 +5,7 @@ import { pushSubs as dbPushSubs, type PushSubRow } from './db.js';
|
||||
|
||||
interface PushSubscriptionEntry {
|
||||
endpoint: string;
|
||||
deviceId?: string;
|
||||
subscription: {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
@@ -33,7 +34,7 @@ export function initPush(config: AppConfig): void {
|
||||
console.log('[push] Generated new VAPID keys');
|
||||
}
|
||||
|
||||
const email = process.env.VAPID_EMAIL || 'noreply@clawtap.local';
|
||||
const email = process.env.VAPID_EMAIL || 'izackp@gmail.com';
|
||||
cachedVapidPublicKey = vapidKeys.publicKey;
|
||||
webpush.setVapidDetails(`mailto:${email}`, vapidKeys.publicKey, vapidKeys.privateKey);
|
||||
|
||||
@@ -41,6 +42,7 @@ export function initPush(config: AppConfig): void {
|
||||
const rows = dbPushSubs.getAll();
|
||||
subscriptions = rows.map(row => ({
|
||||
endpoint: row.endpoint,
|
||||
deviceId: row.device_id ?? undefined,
|
||||
subscription: {
|
||||
endpoint: row.endpoint,
|
||||
keys: { p256dh: row.p256dh, auth: row.auth },
|
||||
@@ -54,12 +56,16 @@ export function getVapidPublicKey(): string | null {
|
||||
return cachedVapidPublicKey;
|
||||
}
|
||||
|
||||
export function saveSubscription(subscription: PushSubscriptionEntry['subscription']): void {
|
||||
// Save to SQLite
|
||||
dbPushSubs.save(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth);
|
||||
// Update in-memory cache
|
||||
subscriptions = subscriptions.filter(s => s.endpoint !== subscription.endpoint);
|
||||
subscriptions.push({ endpoint: subscription.endpoint, subscription });
|
||||
export function saveSubscription(subscription: PushSubscriptionEntry['subscription'], deviceId?: string): void {
|
||||
// Save to SQLite — removes old endpoints for same device_id
|
||||
dbPushSubs.save(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, deviceId ?? null);
|
||||
// Update in-memory cache: remove old entries for same device, add new
|
||||
if (deviceId) {
|
||||
subscriptions = subscriptions.filter(s => s.endpoint === subscription.endpoint || !s.deviceId || s.deviceId !== deviceId);
|
||||
} else {
|
||||
subscriptions = subscriptions.filter(s => s.endpoint !== subscription.endpoint);
|
||||
}
|
||||
subscriptions.push({ endpoint: subscription.endpoint, subscription, deviceId });
|
||||
}
|
||||
|
||||
export function removeSubscription(endpoint: string): void {
|
||||
@@ -89,27 +95,36 @@ export function getPendingSessions(): Record<string, number> {
|
||||
}
|
||||
|
||||
export async function sendPush(payload: unknown): Promise<void> {
|
||||
if (subscriptions.length === 0) return;
|
||||
if (subscriptions.length === 0) {
|
||||
console.log('[push] sendPush: no subscriptions registered, skipping');
|
||||
return;
|
||||
}
|
||||
console.log(`[push] sendPush: sending to ${subscriptions.length} subscription(s)`);
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const expired: string[] = [];
|
||||
let errorCount = 0;
|
||||
|
||||
await Promise.allSettled(
|
||||
subscriptions.map(async ({ endpoint, subscription }) => {
|
||||
try {
|
||||
await webpush.sendNotification(subscription, body);
|
||||
} catch (err) {
|
||||
const e = err as { statusCode?: number; message?: string };
|
||||
if (e.statusCode === 410 || e.statusCode === 404) {
|
||||
// Subscription expired — mark for removal
|
||||
const e = err as { statusCode?: number; message?: string; body?: unknown };
|
||||
const bodyReason = (() => { try { return JSON.parse(e.body as string)?.reason; } catch { return null; } })();
|
||||
if (e.statusCode === 410 || e.statusCode === 404 || bodyReason === 'BadJwtToken') {
|
||||
expired.push(endpoint);
|
||||
} else {
|
||||
console.error(`[push] Failed to send to ${endpoint.slice(0, 50)}:`, e.message);
|
||||
errorCount++;
|
||||
console.error(`[push] Failed to send to ${endpoint.slice(0, 50)}: status=${e.statusCode} msg=${e.message} body=${JSON.stringify(e.body)}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const ok = subscriptions.length - expired.length - errorCount;
|
||||
console.log(`[push] sendPush: done (${ok} ok, ${expired.length} expired, ${errorCount} failed)`);
|
||||
|
||||
// Clean up expired subscriptions
|
||||
if (expired.length > 0) {
|
||||
for (const ep of expired) {
|
||||
|
||||
+135
-20
@@ -7,6 +7,7 @@ import { basename } from 'path';
|
||||
import type { ClientConnection } from './transport/client-connection.js';
|
||||
import { sessionReviews, sessionAdapters } from './db.js';
|
||||
import { parseAskQuestionInput } from './adapters/shared/ask-question-utils.js';
|
||||
import { TaskAggregator, TASK_TOOL_NAMES, TASK_TOOLS_ON_START } from './stores/task-aggregator.js';
|
||||
|
||||
/** Push notification options */
|
||||
interface PushOptions {
|
||||
@@ -15,14 +16,12 @@ interface PushOptions {
|
||||
tagPrefix: string;
|
||||
}
|
||||
|
||||
/** Send a push notification for a session event — only if nobody is viewing this session. */
|
||||
function triggerPush(adapter: IAdapter, sessionId: string, { title, body, tagPrefix }: PushOptions): void {
|
||||
const clients = sessionClients.get(sessionId);
|
||||
if (clients && clients.size > 0) return;
|
||||
|
||||
// Skip push for child review sessions
|
||||
if (sessionReviews.getAllChildIds().has(sessionId)) return;
|
||||
/** Pending push timers: sessionId → timeout handle. Cancelled if client pongs within 2s. */
|
||||
const pendingPushes = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/** Actually send the push notification. */
|
||||
function firePush(adapter: IAdapter, sessionId: string, { title, body, tagPrefix }: PushOptions): void {
|
||||
console.log(`[push] firePush: "${title}" for session ${sessionId.slice(0, 8)}`);
|
||||
const session = adapter.getSession(sessionId) as { cwd?: string } | null;
|
||||
const projectName = basename(session?.cwd || '') || 'Unknown';
|
||||
const badge = incrementPending(sessionId);
|
||||
@@ -34,6 +33,49 @@ function triggerPush(adapter: IAdapter, sessionId: string, { title, body, tagPre
|
||||
}).catch((err: Error) => console.error('[push]', err.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a push notification for a session event.
|
||||
*
|
||||
* Fast path — no clients, or all clients reported hidden via page-visibility:
|
||||
* send immediately.
|
||||
* Fallback — some client is visible or visibility unknown:
|
||||
* broadcast an app-ping; if any client JS responds with app-pong within 2s
|
||||
* the notification is dropped (user is watching). Otherwise send after 2s.
|
||||
*/
|
||||
function queuePush(adapter: IAdapter, sessionId: string, opts: PushOptions): void {
|
||||
// Skip push for child review sessions
|
||||
if (sessionReviews.getAllChildIds().has(sessionId)) return;
|
||||
|
||||
// Cancel any pre-existing pending push for this session
|
||||
const existing = pendingPushes.get(sessionId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
const clients = sessionClients.get(sessionId);
|
||||
|
||||
// No clients connected at all — send immediately
|
||||
if (!clients || clients.size === 0) {
|
||||
console.log(`[push] queuePush: no clients → immediate push`);
|
||||
firePush(adapter, sessionId, opts);
|
||||
return;
|
||||
}
|
||||
|
||||
// All clients already reported page hidden — send immediately (fast path)
|
||||
if ([...clients].every(c => !c.pageVisible)) {
|
||||
console.log(`[push] queuePush: all clients hidden → immediate push`);
|
||||
firePush(adapter, sessionId, opts);
|
||||
return;
|
||||
}
|
||||
|
||||
// Some client may be visible — ping JS and wait up to 2s for a pong response
|
||||
console.log(`[push] queuePush: client(s) may be visible (pageVisible=${[...clients].map(c => c.pageVisible).join(',')}) → pinging, push in 2s if no pong`);
|
||||
broadcast(sessionId, { type: WS.APP_PING, sessionId });
|
||||
const timer = setTimeout(() => {
|
||||
pendingPushes.delete(sessionId);
|
||||
firePush(adapter, sessionId, opts);
|
||||
}, 2000);
|
||||
pendingPushes.set(sessionId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionManager — bridges adapter events to connected clients.
|
||||
*
|
||||
@@ -53,6 +95,22 @@ const sessionAdapterMap = new Map<string, string>(); // sessionId -
|
||||
// under the old key. This alias map resolves old → new so late-connecting clients
|
||||
// find the correct session.
|
||||
const rekeyAliases = new Map<string, string>(); // oldKey -> newKey
|
||||
const sessionTaskState = new Map<string, TaskAggregator>(); // sessionId -> task aggregator
|
||||
|
||||
function getOrCreateAggregator(sessionId: string): TaskAggregator {
|
||||
let agg = sessionTaskState.get(sessionId);
|
||||
if (!agg) {
|
||||
agg = new TaskAggregator();
|
||||
sessionTaskState.set(sessionId, agg);
|
||||
}
|
||||
return agg;
|
||||
}
|
||||
|
||||
function broadcastTaskState(sessionId: string): void {
|
||||
const aggregator = sessionTaskState.get(sessionId);
|
||||
if (!aggregator?.hasTasks) return;
|
||||
broadcast(sessionId, { type: WS.TASK_STATE, ...aggregator.getSnapshot() });
|
||||
}
|
||||
|
||||
export function setupSessionManager(): void {
|
||||
const adapters = getAllAdapters();
|
||||
@@ -69,11 +127,24 @@ export function setupSessionManager(): void {
|
||||
adapter.on('tool-start', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => {
|
||||
console.log(`[mgr] tool-start: ${data.toolName} for ${sessionId}`);
|
||||
broadcast(sessionId, { type: WS.TOOL_START, ...data });
|
||||
|
||||
// TaskUpdate/TodoWrite can be processed on start (input is sufficient).
|
||||
// TaskCreate is deferred to tool-done because we need the result text for the assigned ID.
|
||||
if (TASK_TOOLS_ON_START.has(data.toolName)) {
|
||||
getOrCreateAggregator(sessionId).processToolUse(data.toolName, (data.input as Record<string, unknown>) || {});
|
||||
broadcastTaskState(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
adapter.on('tool-done', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => {
|
||||
adapter.on('tool-done', (sessionId: string, data: { toolName: string; result?: any; [key: string]: unknown }) => {
|
||||
console.log(`[mgr] tool-done: ${data.toolName} for ${sessionId}`);
|
||||
broadcast(sessionId, { type: WS.TOOL_DONE, ...data });
|
||||
|
||||
if (TASK_TOOL_NAMES.has(data.toolName)) {
|
||||
const resultText = typeof data.result?.content === 'string' ? data.result.content : '';
|
||||
getOrCreateAggregator(sessionId).processToolUse(data.toolName, (data.input as Record<string, unknown>) || {}, resultText);
|
||||
broadcastTaskState(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
adapter.on('new-messages', (sessionId: string, messages: Array<{ role: string; [key: string]: unknown }>) => {
|
||||
@@ -92,7 +163,7 @@ export function setupSessionManager(): void {
|
||||
setTimeout(() => {
|
||||
broadcast(sessionId, { type: WS.TURN_COMPLETE, sessionId });
|
||||
}, 100);
|
||||
triggerPush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' });
|
||||
queuePush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' });
|
||||
});
|
||||
|
||||
adapter.on('permission-request', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => {
|
||||
@@ -111,7 +182,7 @@ export function setupSessionManager(): void {
|
||||
{ value: 'deny', label: 'Deny' },
|
||||
],
|
||||
});
|
||||
triggerPush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' });
|
||||
queuePush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' });
|
||||
});
|
||||
|
||||
adapter.on('ask-question', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => {
|
||||
@@ -127,7 +198,7 @@ export function setupSessionManager(): void {
|
||||
options: parsed.options,
|
||||
textInput: parsed.options ? undefined : { placeholder: 'Enter your response...' },
|
||||
});
|
||||
triggerPush(adapter, sessionId, { title: 'Question', body: questionText.substring(0, 50) || 'Waiting for answer', tagPrefix: 'ask' });
|
||||
queuePush(adapter, sessionId, { title: 'Question', body: (parsed.question || '').substring(0, 50) || 'Waiting for answer', tagPrefix: 'ask' });
|
||||
});
|
||||
|
||||
adapter.on('interactive-prompt', (sessionId: string, prompt: any) => {
|
||||
@@ -136,7 +207,7 @@ export function setupSessionManager(): void {
|
||||
: prompt.promptType === 'question' ? 'Question'
|
||||
: prompt.promptType === 'plan' ? 'Plan approval'
|
||||
: 'Action needed';
|
||||
triggerPush(adapter, sessionId, { title: pushTitle, body: prompt.title || '', tagPrefix: 'prompt' });
|
||||
queuePush(adapter, sessionId, { title: pushTitle, body: prompt.title || '', tagPrefix: 'prompt' });
|
||||
});
|
||||
|
||||
adapter.on('status-update', (sessionId: string, status: Record<string, unknown>) => {
|
||||
@@ -166,6 +237,7 @@ export function setupSessionManager(): void {
|
||||
// THEN clean up maps
|
||||
sessionClients.delete(sessionId);
|
||||
sessionAdapterMap.delete(sessionId);
|
||||
sessionTaskState.delete(sessionId);
|
||||
// Clean rekey alias pointing to this session
|
||||
for (const [oldKey, newKey] of rekeyAliases) {
|
||||
if (newKey === sessionId) rekeyAliases.delete(oldKey);
|
||||
@@ -174,7 +246,7 @@ export function setupSessionManager(): void {
|
||||
|
||||
adapter.on('session-error', (sessionId: string, data: { errorType?: string; errorDetails?: string; [key: string]: unknown }) => {
|
||||
broadcast(sessionId, { type: WS.SESSION_ERROR, ...data });
|
||||
triggerPush(adapter, sessionId, {
|
||||
queuePush(adapter, sessionId, {
|
||||
title: 'Session Error',
|
||||
body: data.errorType === 'rate_limit' ? 'Rate limited' : (data.errorDetails || data.errorType || 'Unknown error'),
|
||||
tagPrefix: 'error',
|
||||
@@ -214,6 +286,12 @@ export function setupSessionManager(): void {
|
||||
sessionAdapterMap.delete(oldKey);
|
||||
sessionAdapterMap.set(newKey, adapterName);
|
||||
}
|
||||
// Move task state
|
||||
const taskState = sessionTaskState.get(oldKey);
|
||||
if (taskState) {
|
||||
sessionTaskState.delete(oldKey);
|
||||
sessionTaskState.set(newKey, taskState);
|
||||
}
|
||||
// Update any active reviews that reference the old key as child (FIX 3)
|
||||
sessionReviews.updateChildCliId(oldKey, newKey);
|
||||
// Send SESSION_CREATED with the real UUID — for pendingRekey adapters,
|
||||
@@ -322,6 +400,21 @@ export async function handleIncomingMessage(conn: ClientConnection, msg: ClientM
|
||||
selectedOption: msg.selectedOption as string | undefined,
|
||||
textValue: msg.textValue as string | undefined,
|
||||
});
|
||||
case WS.PAGE_VISIBILITY: {
|
||||
conn.pageVisible = !!(msg as any).visible;
|
||||
return;
|
||||
}
|
||||
case WS.APP_PONG: {
|
||||
const sid = (msg as any).sessionId as string | undefined;
|
||||
if (sid) {
|
||||
const timer = pendingPushes.get(sid);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
pendingPushes.delete(sid);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
conn.send({ type: 'error', error: `Unknown message type: ${msg.type}` });
|
||||
}
|
||||
@@ -438,16 +531,38 @@ export async function handleReconnect(conn: ClientConnection, sessionId?: string
|
||||
// that would duplicate what HISTORY_LOAD delivers
|
||||
adapter.syncWatcherPosition(resolvedId);
|
||||
|
||||
// Send current messages from store (full history for reconnection)
|
||||
const isStreaming = adapter.isProcessing(resolvedId);
|
||||
let historyMessages: unknown[] = [];
|
||||
try {
|
||||
const { messages } = await adapter.getMessages(resolvedId);
|
||||
if (messages.length > 0) {
|
||||
send(conn, { type: WS.HISTORY_LOAD, messages });
|
||||
}
|
||||
({ messages: historyMessages } = await adapter.getMessages(resolvedId));
|
||||
} catch {}
|
||||
// Rebuild task state from history if not already cached (e.g. after server restart)
|
||||
if (!sessionTaskState.has(resolvedId) && historyMessages.length > 0) {
|
||||
const aggregator = new TaskAggregator();
|
||||
for (const msg of historyMessages as Array<{ role?: string; content?: any[] }>) {
|
||||
if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
|
||||
for (const block of msg.content) {
|
||||
if (block.type === 'tool_use' && TASK_TOOL_NAMES.has(block.name)) {
|
||||
const resultText = block._result?.content;
|
||||
aggregator.processToolUse(block.name, block.input || {}, typeof resultText === 'string' ? resultText : undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (aggregator.hasTasks) {
|
||||
sessionTaskState.set(resolvedId, aggregator);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify client if session is actively processing
|
||||
if (adapter.isProcessing(resolvedId)) {
|
||||
send(conn, { type: WS.HISTORY_LOAD, messages: historyMessages, streaming: isStreaming });
|
||||
|
||||
// Send accumulated task state if available
|
||||
const taskAgg = sessionTaskState.get(resolvedId);
|
||||
if (taskAgg?.hasTasks) {
|
||||
send(conn, { type: WS.TASK_STATE, ...taskAgg.getSnapshot() });
|
||||
}
|
||||
|
||||
// Fallback: client may receive broadcasts before HISTORY_LOAD during the async gap
|
||||
if (isStreaming) {
|
||||
send(conn, { type: WS.SESSION_STATE, streaming: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Pure aggregator: processes tool_use blocks (TaskCreate, TaskUpdate, TodoWrite)
|
||||
* and produces a unified AggregatedTask[] snapshot with round-based grouping.
|
||||
*
|
||||
* A new "round" starts when all tasks were completed and a new TaskCreate arrives.
|
||||
* The snapshot exposes both the current round (for FAB) and all tasks (for sheet history).
|
||||
*/
|
||||
|
||||
export interface AggregatedTask {
|
||||
id: string;
|
||||
subject: string;
|
||||
description?: string;
|
||||
activeForm?: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
blockedBy?: string[];
|
||||
blocks?: string[];
|
||||
source: 'task-api' | 'todo-write';
|
||||
round: number;
|
||||
}
|
||||
|
||||
export interface TaskSnapshot {
|
||||
/** All tasks across all rounds */
|
||||
tasks: AggregatedTask[];
|
||||
/** Current round tasks only (for FAB display) */
|
||||
currentRound: AggregatedTask[];
|
||||
/** Completed count in current round */
|
||||
completed: number;
|
||||
/** Total count in current round */
|
||||
total: number;
|
||||
/** Current round number (0-based) */
|
||||
round: number;
|
||||
/** Whether there are previous rounds (for sheet "history" section) */
|
||||
hasHistory: boolean;
|
||||
}
|
||||
|
||||
/** Tool names that produce task state changes. */
|
||||
export const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TodoWrite']);
|
||||
/** Subset handled on tool-start (TaskCreate needs result text, so it's deferred to tool-done). */
|
||||
export const TASK_TOOLS_ON_START = new Set(['TaskUpdate', 'TodoWrite']);
|
||||
|
||||
export class TaskAggregator {
|
||||
private taskApiTasks = new Map<string, AggregatedTask>();
|
||||
private todoWriteTasks = new Map<string, AggregatedTask>();
|
||||
private cachedSnapshot: TaskSnapshot | null = null;
|
||||
private currentRound = 0;
|
||||
private allCompletedBeforeCreate = true; // tracks if all tasks were done before last TaskCreate
|
||||
|
||||
/** Process a single tool_use block. Returns true if state changed. */
|
||||
processToolUse(toolName: string, input: Record<string, unknown>, resultText?: string): boolean {
|
||||
switch (toolName) {
|
||||
case 'TaskCreate': {
|
||||
// Start a new round if all previous tasks were completed
|
||||
if (this.taskApiTasks.size > 0 && this.allCompletedBeforeCreate) {
|
||||
this.currentRound++;
|
||||
}
|
||||
this.allCompletedBeforeCreate = false;
|
||||
|
||||
const match = resultText?.match(/Task #(\d+)/);
|
||||
const id = match?.[1] || String(this.taskApiTasks.size + 1);
|
||||
this.taskApiTasks.set(id, {
|
||||
id,
|
||||
subject: (input.subject as string) || '',
|
||||
description: (input.description as string) || undefined,
|
||||
activeForm: (input.activeForm as string) || undefined,
|
||||
status: 'pending',
|
||||
source: 'task-api',
|
||||
round: this.currentRound,
|
||||
});
|
||||
this.cachedSnapshot = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'TaskUpdate': {
|
||||
const taskId = input.taskId as string;
|
||||
if (!taskId) return false;
|
||||
const existing = this.taskApiTasks.get(taskId);
|
||||
if (!existing) return false;
|
||||
if (input.status === 'deleted') {
|
||||
this.taskApiTasks.delete(taskId);
|
||||
this.cachedSnapshot = null;
|
||||
this.updateCompletionState();
|
||||
return true;
|
||||
}
|
||||
const updated: AggregatedTask = { ...existing };
|
||||
if (input.status) updated.status = input.status as AggregatedTask['status'];
|
||||
if (input.subject) updated.subject = input.subject as string;
|
||||
if (input.description) updated.description = input.description as string;
|
||||
if (input.activeForm) updated.activeForm = input.activeForm as string;
|
||||
if (input.addBlockedBy) {
|
||||
updated.blockedBy = [...(updated.blockedBy || []), ...(input.addBlockedBy as string[])];
|
||||
}
|
||||
if (input.addBlocks) {
|
||||
updated.blocks = [...(updated.blocks || []), ...(input.addBlocks as string[])];
|
||||
}
|
||||
this.taskApiTasks.set(taskId, updated);
|
||||
this.cachedSnapshot = null;
|
||||
this.updateCompletionState();
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'TodoWrite': {
|
||||
// TodoWrite always starts a new round (replaces entire todo list)
|
||||
if (this.todoWriteTasks.size > 0 || this.taskApiTasks.size > 0) {
|
||||
const allDone = this.allTasksCompleted();
|
||||
if (allDone) this.currentRound++;
|
||||
}
|
||||
this.todoWriteTasks.clear();
|
||||
const tasks = (input.tasks || input.todos || []) as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: string;
|
||||
}>;
|
||||
for (const t of tasks) {
|
||||
this.todoWriteTasks.set(t.id, {
|
||||
id: t.id,
|
||||
subject: t.content || '',
|
||||
status: (t.status as AggregatedTask['status']) || 'pending',
|
||||
source: 'todo-write',
|
||||
round: this.currentRound,
|
||||
});
|
||||
}
|
||||
this.cachedSnapshot = null;
|
||||
this.updateCompletionState();
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(): TaskSnapshot {
|
||||
if (this.cachedSnapshot) return this.cachedSnapshot;
|
||||
const tasks: AggregatedTask[] = [
|
||||
...this.taskApiTasks.values(),
|
||||
...this.todoWriteTasks.values(),
|
||||
];
|
||||
const currentRound = tasks.filter(t => t.round === this.currentRound);
|
||||
const completed = currentRound.filter(t => t.status === 'completed').length;
|
||||
this.cachedSnapshot = {
|
||||
tasks,
|
||||
currentRound,
|
||||
completed,
|
||||
total: currentRound.length,
|
||||
round: this.currentRound,
|
||||
hasHistory: this.currentRound > 0,
|
||||
};
|
||||
return this.cachedSnapshot;
|
||||
}
|
||||
|
||||
get hasTasks(): boolean {
|
||||
return this.taskApiTasks.size > 0 || this.todoWriteTasks.size > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.taskApiTasks.clear();
|
||||
this.todoWriteTasks.clear();
|
||||
this.cachedSnapshot = null;
|
||||
this.currentRound = 0;
|
||||
this.allCompletedBeforeCreate = true;
|
||||
}
|
||||
|
||||
private allTasksCompleted(): boolean {
|
||||
for (const t of this.taskApiTasks.values()) {
|
||||
if (t.status !== 'completed') return false;
|
||||
}
|
||||
for (const t of this.todoWriteTasks.values()) {
|
||||
if (t.status !== 'completed') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Update the flag that tracks whether all tasks are done (for round detection). */
|
||||
private updateCompletionState(): void {
|
||||
this.allCompletedBeforeCreate = this.allTasksCompleted();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export abstract class ClientConnection {
|
||||
readonly transportName: string;
|
||||
sessionId: string | null = null;
|
||||
onDisconnect: ((conn: ClientConnection) => void) | null = null;
|
||||
/** True while the client tab/app is in the foreground. Starts true (assume visible until told otherwise). */
|
||||
pageVisible: boolean = true;
|
||||
|
||||
constructor(transportName: string) {
|
||||
this.transportName = transportName;
|
||||
|
||||
@@ -18,6 +18,9 @@ import type { ClientMessage } from '../types/messages.js';
|
||||
export class WebSocketTransport extends EventEmitter {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
// Tracks whether each WS responded to the last ping. Initialized true so a
|
||||
// connection is never terminated before it has a chance to respond.
|
||||
private alive = new WeakMap<WebSocket, boolean>();
|
||||
|
||||
/** Create WebSocketServer on /ws path with JWT verification and ping/pong keepalive. */
|
||||
setup(server: HttpServer | HttpsServer): void {
|
||||
@@ -36,6 +39,9 @@ export class WebSocketTransport extends EventEmitter {
|
||||
});
|
||||
|
||||
this.wss.on('connection', (ws: WebSocket) => {
|
||||
this.alive.set(ws, true);
|
||||
ws.on('pong', () => this.alive.set(ws, true));
|
||||
|
||||
const conn = new WebSocketConnection(ws);
|
||||
|
||||
this.emit('connection', conn);
|
||||
@@ -52,13 +58,16 @@ export class WebSocketTransport extends EventEmitter {
|
||||
});
|
||||
});
|
||||
|
||||
// Ping/pong keepalive every 30s
|
||||
// Ping/pong keepalive every 30s — terminate connections that miss a pong.
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (!this.wss) return;
|
||||
for (const ws of this.wss.clients) {
|
||||
if (ws.readyState === 1) {
|
||||
ws.ping();
|
||||
if (!this.alive.get(ws)) {
|
||||
ws.terminate(); // no pong since last ping — dead connection
|
||||
continue;
|
||||
}
|
||||
this.alive.set(ws, false);
|
||||
if (ws.readyState === 1) ws.ping();
|
||||
}
|
||||
}, 30_000);
|
||||
this.pingInterval.unref();
|
||||
|
||||
@@ -14,7 +14,8 @@ export interface ServerMessage {
|
||||
|
||||
export type ClientMessageType =
|
||||
| 'query' | 'permission-response' | 'ask-response' | 'abort'
|
||||
| 'reconnect' | 'set-permission-mode' | 'plan-response';
|
||||
| 'reconnect' | 'set-permission-mode' | 'plan-response'
|
||||
| 'page-visibility' | 'app-pong';
|
||||
|
||||
export interface ClientMessage {
|
||||
type: ClientMessageType;
|
||||
|
||||
@@ -8,6 +8,8 @@ export const WS = {
|
||||
SET_PERMISSION_MODE: 'set-permission-mode',
|
||||
SET_MODEL: 'set-model',
|
||||
PLAN_RESPONSE: 'plan-response',
|
||||
PAGE_VISIBILITY: 'page-visibility',
|
||||
APP_PONG: 'app-pong',
|
||||
// Server → Client
|
||||
SESSION_STATE: 'session-state',
|
||||
SESSION_CREATED: 'session-created',
|
||||
@@ -37,6 +39,10 @@ export const WS = {
|
||||
// Cross-AI Review
|
||||
REVIEW_STARTED: 'review-started',
|
||||
REVIEW_ENDED: 'review-ended',
|
||||
// Task Progress
|
||||
TASK_STATE: 'task-state',
|
||||
// Push notification coordination
|
||||
APP_PING: 'app-ping',
|
||||
} as const;
|
||||
|
||||
export type WsType = typeof WS[keyof typeof WS];
|
||||
|
||||
+91
-33
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { STORAGE } from './lib/storage-keys';
|
||||
import { isAuthenticated, clearToken } from './lib/api';
|
||||
import { usePushNotifications } from './hooks/usePushNotifications';
|
||||
import { LoginView } from './components/LoginView';
|
||||
import { SessionsView } from './components/SessionsView';
|
||||
import { ChatView } from './components/ChatView';
|
||||
@@ -36,7 +37,7 @@ function persistView(view: View) {
|
||||
sessionStorage.setItem('currentView', JSON.stringify(view));
|
||||
}
|
||||
|
||||
function navigateTo(view: View) {
|
||||
function navigateTo(view: View, replace = false) {
|
||||
persistView(view);
|
||||
let url = '/';
|
||||
if (view.name === 'chat' && view.sessionId) {
|
||||
@@ -45,7 +46,11 @@ function navigateTo(view: View) {
|
||||
} else if (view.name === 'settings') {
|
||||
url = '/?view=settings';
|
||||
}
|
||||
window.history.pushState({ view }, '', url);
|
||||
if (replace) {
|
||||
window.history.replaceState({ view }, '', url);
|
||||
} else {
|
||||
window.history.pushState({ view }, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
@@ -55,6 +60,7 @@ export function App() {
|
||||
const [deviceOnline, setDeviceOnline] = useState(navigator.onLine);
|
||||
const consecutiveFails = useRef(0);
|
||||
const initialized = useRef(false);
|
||||
const { supported: pushSupported, subscribed: pushSubscribed, subscribe: pushSubscribe } = usePushNotifications();
|
||||
|
||||
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installDismissed, setInstallDismissed] = useState(
|
||||
@@ -110,12 +116,57 @@ export function App() {
|
||||
}, [dismissInstall]);
|
||||
|
||||
// PWA: Service worker update notification
|
||||
// Only show banner when replacing an existing controller (real update),
|
||||
// not on first registration when clients.claim() fires controllerchange
|
||||
// on a previously uncontrolled page.
|
||||
useEffect(() => {
|
||||
const handleControllerChange = () => setSwUpdateAvailable(true);
|
||||
navigator.serviceWorker?.addEventListener('controllerchange', handleControllerChange);
|
||||
return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange);
|
||||
if (!navigator.serviceWorker) return;
|
||||
let hadController = !!navigator.serviceWorker.controller;
|
||||
const handleControllerChange = () => {
|
||||
if (hadController) setSwUpdateAvailable(true);
|
||||
hadController = true;
|
||||
};
|
||||
navigator.serviceWorker.addEventListener('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
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
@@ -225,53 +276,68 @@ export function App() {
|
||||
// Navigate to chat view with cwd — ChatView will pick up globals and send the prompt
|
||||
const chatCwd = view.name === 'newchat' ? view.cwd : undefined;
|
||||
const v: View = { name: 'chat', cwd: chatCwd, initialPrompt: options.prompt, adapter: options.adapter };
|
||||
navigateTo(v);
|
||||
navigateTo(v, true); // replace newchat → chat so back goes to session list
|
||||
setView(v);
|
||||
}, [view]);
|
||||
|
||||
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
|
||||
if (serverOnline === null) {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg flex items-center justify-center">
|
||||
<LoadingAnimation size="lg" label="Connecting..." />
|
||||
</div>
|
||||
<>
|
||||
<div className="min-h-screen bg-bg flex items-center justify-center">
|
||||
<LoadingAnimation size="lg" label="Connecting..." />
|
||||
</div>
|
||||
{updateBanner}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Offline screen
|
||||
if (isOffline) {
|
||||
return <OfflineView onRetry={checkHealth} />;
|
||||
return <><OfflineView onRetry={checkHealth} />{updateBanner}</>;
|
||||
}
|
||||
|
||||
if (!authed) {
|
||||
return <LoginView onLogin={handleLogin} />;
|
||||
return <><LoginView onLogin={handleLogin} />{updateBanner}</>;
|
||||
}
|
||||
|
||||
if (view.name === 'newchat') {
|
||||
return (
|
||||
<NewChatView
|
||||
cwd={view.cwd}
|
||||
onStartChat={startChat}
|
||||
onBack={backToSessions}
|
||||
/>
|
||||
<>
|
||||
<NewChatView cwd={view.cwd} onStartChat={startChat} onBack={backToSessions} />
|
||||
{updateBanner}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.name === 'settings') {
|
||||
return <SettingsView onBack={() => setView({ name: 'sessions' })} />;
|
||||
return <><SettingsView onBack={() => setView({ name: 'sessions' })} />{updateBanner}</>;
|
||||
}
|
||||
|
||||
if (view.name === 'chat') {
|
||||
return (
|
||||
<ChatView
|
||||
sessionId={view.sessionId}
|
||||
cwd={view.cwd}
|
||||
initialPrompt={view.initialPrompt}
|
||||
adapter={view.adapter}
|
||||
onBack={backToSessions}
|
||||
/>
|
||||
<>
|
||||
<ChatView
|
||||
sessionId={view.sessionId}
|
||||
cwd={view.cwd}
|
||||
initialPrompt={view.initialPrompt}
|
||||
adapter={view.adapter}
|
||||
onBack={backToSessions}
|
||||
/>
|
||||
{updateBanner}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -285,15 +351,7 @@ export function App() {
|
||||
onInstall={handleInstall}
|
||||
onDismissInstall={dismissInstall}
|
||||
/>
|
||||
{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 cursor-pointer">Refresh</button>
|
||||
<button onClick={() => setSwUpdateAvailable(false)} className="text-sm text-text-dim hover:text-text cursor-pointer">Later</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{updateBanner}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+39
-16
@@ -1,8 +1,8 @@
|
||||
import React, { useRef, useState, useEffect, useMemo, Fragment } from 'react';
|
||||
import React, { useRef, useState, useEffect, useCallback, useMemo, Fragment } from 'react';
|
||||
import { ArrowDownToLine } from 'lucide-react';
|
||||
import type { ChatMessage, ToolStatus } from '../hooks/useChat';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { TaskProgress } from './TaskProgress';
|
||||
import { SubagentGroup } from './SubagentGroup';
|
||||
import { ShimmerInput } from './ShimmerInput';
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface ChatBodyProps {
|
||||
toolStatuses: Map<string, ToolStatus>;
|
||||
onSend: (text: string) => void;
|
||||
onStop: () => void;
|
||||
disabled: boolean;
|
||||
interrupted: boolean;
|
||||
sendTargets?: { adapter: string; label: string }[];
|
||||
onSendTo?: (messageId: string, adapter?: string) => void;
|
||||
@@ -48,7 +47,6 @@ export function ChatBody({
|
||||
toolStatuses,
|
||||
onSend,
|
||||
onStop,
|
||||
disabled,
|
||||
interrupted,
|
||||
sendTargets,
|
||||
onSendTo,
|
||||
@@ -67,14 +65,31 @@ export function ChatBody({
|
||||
const scrollRef = scrollContainerRef || internalRef;
|
||||
const [userScrolled, setUserScrolled] = useState(false);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive, unless user scrolled up
|
||||
const isAutoScrolling = useRef(false);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (smooth) {
|
||||
isAutoScrolling.current = true;
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
setTimeout(() => { isAutoScrolling.current = false; }, 800);
|
||||
} else {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
});
|
||||
}, [scrollRef]);
|
||||
|
||||
// Auto-scroll when new content arrives
|
||||
useEffect(() => {
|
||||
if (!userScrolled && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
if (!userScrolled) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages, userScrolled]);
|
||||
}, [messages, streaming, userScrolled, scrollToBottom]);
|
||||
|
||||
function handleScroll() {
|
||||
if (isAutoScrolling.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const scrolled = el.scrollHeight - el.scrollTop - el.clientHeight >= 100;
|
||||
@@ -95,10 +110,9 @@ export function ChatBody({
|
||||
? new Set(content.filter((b: any) => b.type === 'tool_result').map((b: any) => b.tool_use_id))
|
||||
: null;
|
||||
|
||||
const taskBlocks = toolBlocks.filter((b: any) => b.name === 'TodoWrite');
|
||||
const planBlocks = toolBlocks.filter((b: any) => b.name === 'ExitPlanMode' && b.input?.plan);
|
||||
const regularTools = toolBlocks.filter(
|
||||
(b: any) => !['TodoWrite', 'EnterPlanMode', 'ExitPlanMode'].includes(b.name),
|
||||
(b: any) => !['TodoWrite', 'TaskCreate', 'TaskUpdate', 'EnterPlanMode', 'ExitPlanMode'].includes(b.name),
|
||||
);
|
||||
|
||||
const subagentGroups = new Map<string, any[]>();
|
||||
@@ -151,10 +165,6 @@ export function ChatBody({
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of taskBlocks) {
|
||||
elements.push(<TaskProgress key={task.id} input={task.input} />);
|
||||
}
|
||||
|
||||
for (const plan of planBlocks) {
|
||||
if (renderPlanBlock) {
|
||||
const node = renderPlanBlock(plan, hasPlanResponse, plan.id);
|
||||
@@ -168,7 +178,7 @@ export function ChatBody({
|
||||
return (
|
||||
<div className={className ? `flex flex-col min-h-0 ${className}` : 'flex flex-col min-h-0 flex-1'}>
|
||||
{/* Scroll container */}
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-4 pt-14 pb-4">
|
||||
{messages.length === 0 && !streaming && (
|
||||
<div className="text-text-dim text-sm text-center py-20 font-mono">Send a message to start</div>
|
||||
)}
|
||||
@@ -243,12 +253,25 @@ export function ChatBody({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll-to-bottom button — sits just above the footer area */}
|
||||
<div className="relative shrink-0">
|
||||
{userScrolled && (
|
||||
<button
|
||||
onClick={() => { setUserScrolled(false); scrollToBottom(true); }}
|
||||
className="absolute -top-10 right-4 w-8 h-8 rounded-full bg-surface border border-border flex items-center justify-center shadow-lg hover:bg-surface-light transition-colors z-10"
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
<ArrowDownToLine className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderAboveInput?.()}
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 px-4 py-2 safe-bottom">
|
||||
{!hideInput ? (
|
||||
<ShimmerInput onSend={onSend} onStop={onStop} disabled={disabled} streaming={streaming} interrupted={interrupted} initialText={initialInputText} placeholder={inputPlaceholder} />
|
||||
<ShimmerInput onSend={onSend} onStop={onStop} disabled={false} streaming={streaming} interrupted={interrupted} initialText={initialInputText} placeholder={inputPlaceholder} />
|
||||
) : (
|
||||
<div className="px-4 py-3 text-center text-text-dim/40 text-xs italic">
|
||||
Review ended — read only
|
||||
|
||||
+39
-13
@@ -10,6 +10,8 @@ import { ReviewActionMenu } from './ReviewActionMenu';
|
||||
import { SendToExistingSheet } from './SendToExistingSheet';
|
||||
import { CollapsedReviewCard } from './CollapsedReviewCard';
|
||||
import { BlockMarker } from './BlockMarker';
|
||||
import { TaskFab } from './TaskFab';
|
||||
import { TaskBottomSheet } from './TaskBottomSheet';
|
||||
import { api } from '../lib/api';
|
||||
import { getBrand } from '../lib/adapter-brands';
|
||||
import { extractTextFromBlocks } from '../lib/content-utils';
|
||||
@@ -27,7 +29,7 @@ function PlanViewer({ plan }: { plan: string }) {
|
||||
if (expanded) {
|
||||
return (
|
||||
<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>
|
||||
<Button variant="ghost" size="icon" onClick={() => setExpanded(false)}>
|
||||
<X className="w-5 h-5" />
|
||||
@@ -95,25 +97,41 @@ function ChatHeader({ sessionId, cwd }: { sessionId?: string; cwd?: string }) {
|
||||
}
|
||||
|
||||
|
||||
/** Tracks scroll direction inside a child scroll container to auto-hide/show the header. */
|
||||
/** Auto-hide header during scroll, show when scroll stops or at bottom. */
|
||||
function useAutoHideHeader(scrollRef: RefObject<HTMLDivElement | null>) {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
const stopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
function onScroll() {
|
||||
const st = el!.scrollTop;
|
||||
const delta = st - lastScrollTop.current;
|
||||
// delta > 0 means scrollTop increased (user scrolled toward bottom/latest)
|
||||
// delta < 0 means scrollTop decreased (user scrolled toward top/history)
|
||||
if (delta > 8) setHidden(false); // toward latest → show header
|
||||
else if (delta < -8) setHidden(true); // toward history → hide header
|
||||
lastScrollTop.current = st;
|
||||
|
||||
// Don't hide when at the bottom (viewing latest messages)
|
||||
const atBottom = el!.scrollHeight - st - el!.clientHeight < 50;
|
||||
if (atBottom) {
|
||||
if (stopTimer.current) clearTimeout(stopTimer.current);
|
||||
setHidden(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(delta) > 8) setHidden(true);
|
||||
|
||||
// Show header after scroll stops
|
||||
if (stopTimer.current) clearTimeout(stopTimer.current);
|
||||
stopTimer.current = setTimeout(() => setHidden(false), 1000);
|
||||
}
|
||||
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
if (stopTimer.current) clearTimeout(stopTimer.current);
|
||||
};
|
||||
}, [scrollRef]);
|
||||
|
||||
return hidden;
|
||||
@@ -143,10 +161,12 @@ export function ChatView({
|
||||
queuedMessage, clearQueuedMessage,
|
||||
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
|
||||
historyReview, setHistoryReview,
|
||||
taskSnapshot,
|
||||
sendMessage, respondPrompt, respondPlan, abort,
|
||||
updateModel, updatePermissionMode,
|
||||
} = useChat(initialSessionId, cwd, adapter, initialPrompt);
|
||||
|
||||
const [taskSheetOpen, setTaskSheetOpen] = useState(false);
|
||||
const [availableAdapters, setAvailableAdapters] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
api.adapters()
|
||||
@@ -470,16 +490,19 @@ export function ChatView({
|
||||
|
||||
if (!selectedAdapter) {
|
||||
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..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-bg relative overflow-hidden safe-top">
|
||||
{/* Header — auto-hides when scrolling up to view history */}
|
||||
<div className={`flex items-center gap-2 px-4 py-3 border-b border-border shrink-0 transition-all duration-200 ${headerHidden ? 'max-h-0 py-0 overflow-hidden opacity-0 border-b-0' : 'max-h-16 opacity-100'}`}>
|
||||
<div
|
||||
className="flex flex-col bg-bg relative overflow-hidden safe-top"
|
||||
style={{ height: 'var(--app-height, 100dvh)' }}
|
||||
>
|
||||
{/* 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'}`}>
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
@@ -498,7 +521,6 @@ export function ChatView({
|
||||
toolStatuses={toolStatuses}
|
||||
onSend={sendMessage}
|
||||
onStop={abort}
|
||||
disabled={false}
|
||||
interrupted={interrupted}
|
||||
sendTargets={sendTargets}
|
||||
onSendTo={handleSendTo}
|
||||
@@ -567,7 +589,7 @@ export function ChatView({
|
||||
|
||||
{/* Save-as-instruction toast */}
|
||||
{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>
|
||||
<button
|
||||
className="text-sm text-accent font-medium px-3 py-1 rounded-md hover:bg-accent/10"
|
||||
@@ -579,6 +601,10 @@ export function ChatView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task progress FAB + bottom sheet */}
|
||||
<TaskFab snapshot={taskSnapshot} onClick={() => setTaskSheetOpen(true)} />
|
||||
<TaskBottomSheet snapshot={taskSnapshot} open={taskSheetOpen} onClose={() => setTaskSheetOpen(false)} />
|
||||
|
||||
{/* Interactive prompt overlay (permissions, questions, plan approval, etc.) */}
|
||||
{interactivePrompt && (
|
||||
<InteractivePromptOverlay
|
||||
|
||||
@@ -8,7 +8,7 @@ export function DiffViewer({ filePath, oldString, newString, onClose }: {
|
||||
const newLines = newString.split('\n');
|
||||
return (
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
|
||||
@@ -97,7 +97,6 @@ const ReviewTab = React.memo(function ReviewTab({ review, cwd, onSessionCreated,
|
||||
toolStatuses={toolStatuses || new Map()}
|
||||
onSend={sendMessage}
|
||||
onStop={abort}
|
||||
disabled={false}
|
||||
interrupted={false}
|
||||
onSendBack={readOnly ? undefined : handleSendBack}
|
||||
hideInput={readOnly}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function PlanMode({ input, onApprove, onApproveYolo, onReject, onSendFeed
|
||||
if (showFull) {
|
||||
return (
|
||||
<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>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowFull(false)}>
|
||||
<X className="size-4" />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AdapterTabs } from './AdapterTabs';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
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 { api } from '@/lib/api';
|
||||
import { getBrand, ADAPTER_BRANDS } from '@/lib/adapter-brands';
|
||||
@@ -41,7 +41,7 @@ export function SessionsView({
|
||||
activeSessionIds,
|
||||
refreshActive,
|
||||
} = useSessions();
|
||||
const { supported: pushSupported, subscribed, subscribe, unsubscribe } = usePushNotifications();
|
||||
const { subscribed } = usePushNotifications();
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [showHeaderMenu, setShowHeaderMenu] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@@ -132,7 +132,7 @@ export function SessionsView({
|
||||
if (selectedProjectDir) {
|
||||
return (
|
||||
<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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -256,15 +256,6 @@ export function SessionsView({
|
||||
<>
|
||||
<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">
|
||||
{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
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { CheckCircle2, Circle, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Progress } from './ui/progress';
|
||||
import { BottomSheet } from './BottomSheet';
|
||||
import type { AggregatedTask, TaskSnapshot } from '../hooks/useTaskState';
|
||||
|
||||
interface TaskBottomSheetProps {
|
||||
snapshot: TaskSnapshot;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function TaskRow({ task, taskMap }: { task: AggregatedTask; taskMap: Map<string, AggregatedTask> }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const blockers = useMemo(() => {
|
||||
if (!task.blockedBy?.length) return [];
|
||||
return task.blockedBy
|
||||
.map(id => taskMap.get(`${task.source}:${id}`))
|
||||
.filter((b): b is AggregatedTask => !!b && b.status !== 'completed');
|
||||
}, [task.blockedBy, task.source, taskMap]);
|
||||
|
||||
const isBlocked = blockers.length > 0;
|
||||
const hasExpandable = task.description || task.activeForm;
|
||||
|
||||
return (
|
||||
<div className={`py-2 border-b border-border/50 last:border-b-0 ${isBlocked ? 'opacity-50' : ''}`}>
|
||||
<div
|
||||
className={`flex items-start gap-2.5 ${hasExpandable ? 'cursor-pointer' : ''}`}
|
||||
onClick={() => hasExpandable && setExpanded(!expanded)}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{task.status === 'completed' ? (
|
||||
<CheckCircle2 className="size-4 text-success" />
|
||||
) : task.status === 'in_progress' ? (
|
||||
<Loader2 className="size-4 text-accent animate-spin" />
|
||||
) : (
|
||||
<Circle className="size-4 text-text-dim" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className={`text-sm flex-1 ${
|
||||
task.status === 'completed' ? 'text-text-dim line-through' :
|
||||
task.status === 'in_progress' ? 'text-text font-medium' :
|
||||
'text-text-secondary'
|
||||
}`}>
|
||||
{task.subject}
|
||||
</span>
|
||||
|
||||
{isBlocked && (
|
||||
<span className="text-[10px] bg-warning/20 text-warning px-1.5 py-0.5 rounded shrink-0">
|
||||
blocked
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasExpandable && (
|
||||
<span className="shrink-0 mt-0.5">
|
||||
{expanded ? (
|
||||
<ChevronUp className="size-3.5 text-text-dim" />
|
||||
) : (
|
||||
<ChevronDown className="size-3.5 text-text-dim" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.status === 'in_progress' && task.activeForm && (
|
||||
<div className="ml-7 mt-1 text-xs text-accent italic">
|
||||
{task.activeForm}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBlocked && (
|
||||
<div className="ml-7 mt-1 text-[11px] text-text-dim">
|
||||
<span className="text-warning">↳</span> waiting: {blockers.map(b => b.subject).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && task.description && (
|
||||
<div className="ml-7 mt-2 px-2.5 py-2 bg-surface-light rounded-md text-xs text-text-secondary leading-relaxed">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistorySection({ tasks, taskMap }: { tasks: AggregatedTask[]; taskMap: Map<string, AggregatedTask> }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/30 mt-2 pt-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center justify-between w-full text-xs text-text-dim py-1"
|
||||
>
|
||||
<span>Previous tasks ({completed}/{tasks.length})</span>
|
||||
{expanded ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
|
||||
</button>
|
||||
{expanded && tasks.map(task => (
|
||||
<TaskRow key={`${task.source}:${task.id}`} task={task} taskMap={taskMap} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskBottomSheet({ snapshot, open, onClose }: TaskBottomSheetProps) {
|
||||
const { tasks, currentRound, completed, total, hasHistory } = snapshot;
|
||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
const taskMap = useMemo(() => {
|
||||
const map = new Map<string, AggregatedTask>();
|
||||
for (const t of tasks) map.set(`${t.source}:${t.id}`, t);
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
const historyTasks = useMemo(() => {
|
||||
if (!hasHistory) return [];
|
||||
const currentIds = new Set(currentRound.map(t => `${t.source}:${t.id}`));
|
||||
return tasks.filter(t => !currentIds.has(`${t.source}:${t.id}`));
|
||||
}, [tasks, currentRound, hasHistory]);
|
||||
|
||||
return (
|
||||
<BottomSheet visible={open} onClose={onClose} className="max-h-[70vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 pb-2">
|
||||
<span className="text-sm font-semibold text-text">Tasks</span>
|
||||
<span className="text-xs text-success font-mono">{completed}/{total}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-3">
|
||||
<Progress value={pct} className="h-1.5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
{currentRound.map(task => (
|
||||
<TaskRow
|
||||
key={`${task.source}:${task.id}`}
|
||||
task={task}
|
||||
taskMap={taskMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
{historyTasks.length > 0 && (
|
||||
<HistorySection tasks={historyTasks} taskMap={taskMap} />
|
||||
)}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { TaskSnapshot } from '../hooks/useTaskState';
|
||||
|
||||
type FabState = 'hidden' | 'visible' | 'fading';
|
||||
|
||||
interface TaskFabProps {
|
||||
snapshot: TaskSnapshot;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TaskFab({ snapshot, onClick }: TaskFabProps) {
|
||||
const { completed, total } = snapshot;
|
||||
const [fabState, setFabState] = useState<FabState>('hidden');
|
||||
const hasBeenVisible = useRef(false);
|
||||
|
||||
const allDone = total > 0 && completed === total;
|
||||
const pct = total > 0 ? completed / total : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (total === 0) {
|
||||
setFabState('hidden');
|
||||
hasBeenVisible.current = false;
|
||||
return;
|
||||
}
|
||||
// Skip showing FAB if all tasks were already done on first load (reconnect)
|
||||
if (allDone && !hasBeenVisible.current) return;
|
||||
hasBeenVisible.current = true;
|
||||
setFabState('visible');
|
||||
if (allDone) {
|
||||
const timer = setTimeout(() => setFabState('fading'), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [total, allDone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fabState !== 'fading') return;
|
||||
const timer = setTimeout(() => setFabState('hidden'), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [fabState]);
|
||||
|
||||
if (fabState === 'hidden') return null;
|
||||
|
||||
const size = 48;
|
||||
const strokeWidth = 3;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference * (1 - pct);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="fixed bottom-20 right-4 z-20 safe-bottom transition-opacity duration-500"
|
||||
style={{ opacity: fabState === 'fading' ? 0 : 1 }}
|
||||
aria-label={`Tasks: ${completed} of ${total} completed`}
|
||||
>
|
||||
<svg width={size} height={size} className="drop-shadow-lg">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="var(--color-surface)"
|
||||
stroke="var(--color-border)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={allDone ? 'var(--color-success)' : 'var(--color-accent)'}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all duration-500 ease-out"
|
||||
/>
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill={allDone ? 'var(--color-success)' : 'var(--color-text)'}
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{completed}/{total}
|
||||
</text>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Circle, CircleDot, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
type TodoItem = { id: string; content: string; status: 'pending' | 'in_progress' | 'completed' };
|
||||
|
||||
export function TaskProgress({ input }: { input: any }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const tasks: TodoItem[] = input?.tasks || input?.todos || [];
|
||||
if (tasks.length === 0) return null;
|
||||
const completed = tasks.filter((t) => t.status === 'completed').length;
|
||||
const pct = Math.round((completed / tasks.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-full flex items-center justify-between mb-2"
|
||||
>
|
||||
<span className="text-xs text-text-dim">Tasks ({completed}/{tasks.length})</span>
|
||||
{collapsed ? (
|
||||
<ChevronDown className="size-3.5 text-text-dim" />
|
||||
) : (
|
||||
<ChevronUp className="size-3.5 text-text-dim" />
|
||||
)}
|
||||
</button>
|
||||
<Progress value={pct} className="mb-2" />
|
||||
{!collapsed && (
|
||||
<div className="space-y-1.5">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-start gap-2">
|
||||
<span className="mt-0.5">
|
||||
{task.status === 'completed' ? (
|
||||
<CheckCircle2 className="size-3.5 text-success" />
|
||||
) : task.status === 'in_progress' ? (
|
||||
<CircleDot className="size-3.5 text-accent" />
|
||||
) : (
|
||||
<Circle className="size-3.5 text-text-dim" />
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm ${task.status === 'completed' ? 'text-text-dim line-through' : 'text-text'}`}>
|
||||
{task.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+33
-11
@@ -6,6 +6,7 @@ import { api } from '../lib/api';
|
||||
import { patchAdapterPrefs, loadAdapterPrefs } from '../lib/adapter-prefs';
|
||||
import { stripMarker } from '@/lib/content-utils';
|
||||
import { parseAskQuestionInput } from '@/lib/ask-question-utils';
|
||||
import { useTaskState } from './useTaskState';
|
||||
|
||||
export type ChatMessage = {
|
||||
id?: string;
|
||||
@@ -153,6 +154,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
|
||||
const [activeReviewPanel, setActiveReviewPanel] = useState<'expanded' | 'minimized'>('expanded');
|
||||
const [historyReview, setHistoryReview] = useState<any>(null);
|
||||
const { taskSnapshot, handleTaskState, resetTasks } = useTaskState();
|
||||
|
||||
const queuedRef = useRef<string | null>(null);
|
||||
const streamingRef = useRef(false);
|
||||
@@ -177,6 +179,13 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
}
|
||||
}, []);
|
||||
|
||||
const enterStreaming = useCallback(() => {
|
||||
if (!streamingRef.current) setInterrupted(false);
|
||||
setStreaming(true);
|
||||
setPendingResponse(true);
|
||||
streamingRef.current = true;
|
||||
}, []);
|
||||
|
||||
// --- WebSocket Message Handler ---
|
||||
const handleWsMessage = useCallback((msg: any) => {
|
||||
switch (msg.type) {
|
||||
@@ -196,6 +205,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
} else {
|
||||
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
|
||||
}
|
||||
resetTasks();
|
||||
break;
|
||||
|
||||
case WS.CLIENT_ID:
|
||||
@@ -399,22 +409,22 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
break;
|
||||
|
||||
case WS.SESSION_STATE:
|
||||
if (msg.streaming) {
|
||||
if (!streamingRef.current) {
|
||||
setInterrupted(false);
|
||||
}
|
||||
setStreaming(true);
|
||||
setPendingResponse(true);
|
||||
streamingRef.current = true;
|
||||
}
|
||||
if (msg.streaming) enterStreaming();
|
||||
break;
|
||||
|
||||
// Full history load on reconnection (replaces, not appends)
|
||||
case WS.HISTORY_LOAD:
|
||||
if (msg.messages && Array.isArray(msg.messages)) {
|
||||
setMessages(convertMessages(msg.messages));
|
||||
}
|
||||
setPendingResponse(false);
|
||||
if (msg.streaming) {
|
||||
enterStreaming();
|
||||
} else {
|
||||
setStreaming(false);
|
||||
setPendingResponse(false);
|
||||
setStreamingText('');
|
||||
setThinkingStatus(null);
|
||||
streamingRef.current = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case WS.STATUS_UPDATE:
|
||||
@@ -464,8 +474,19 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
streamingRef.current = false;
|
||||
console.error('Server error:', msg.error);
|
||||
break;
|
||||
|
||||
case WS.TASK_STATE:
|
||||
handleTaskState({
|
||||
tasks: msg.tasks,
|
||||
currentRound: msg.currentRound,
|
||||
completed: msg.completed,
|
||||
total: msg.total,
|
||||
round: msg.round,
|
||||
hasHistory: msg.hasHistory,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [drainQueue]);
|
||||
}, [drainQueue, enterStreaming, handleTaskState, resetTasks]);
|
||||
|
||||
// --- WebSocket Connection ---
|
||||
useEffect(() => {
|
||||
@@ -645,6 +666,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
queuedMessage, clearQueuedMessage,
|
||||
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
|
||||
historyReview, setHistoryReview,
|
||||
taskSnapshot,
|
||||
sendMessage, respondPermission, respondAsk, respondPrompt, respondPlan, abort,
|
||||
updateModel, updatePermissionMode, updateAdapter,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,16 @@ function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
return outputArray.buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
function getOrCreateDeviceId(): string {
|
||||
const key = 'clawtap_device_id';
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function usePushNotifications() {
|
||||
const [permission, setPermission] = useState<NotificationPermission>(
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'denied'
|
||||
@@ -54,8 +64,8 @@ export function usePushNotifications() {
|
||||
});
|
||||
}
|
||||
|
||||
// Send subscription to server
|
||||
await api.pushSubscribe(sub.toJSON());
|
||||
// Send subscription to server with stable device ID
|
||||
await api.pushSubscribe(sub.toJSON(), getOrCreateDeviceId());
|
||||
setSubscribed(true);
|
||||
return true;
|
||||
}, [supported]);
|
||||
|
||||
@@ -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 { STORAGE } from '../lib/storage-keys';
|
||||
|
||||
@@ -8,7 +8,14 @@ export function useSessions() {
|
||||
() => localStorage.getItem(STORAGE.PROJECT_DIR)
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'projects' | 'active'>('projects');
|
||||
const [activeTab, _setActiveTab] = useState<'projects' | 'active'>(() => {
|
||||
const stored = sessionStorage.getItem(STORAGE.SESSIONS_TAB);
|
||||
return stored === 'active' ? 'active' : 'projects';
|
||||
});
|
||||
const setActiveTab = useCallback((tab: 'projects' | 'active') => {
|
||||
_setActiveTab(tab);
|
||||
sessionStorage.setItem(STORAGE.SESSIONS_TAB, tab);
|
||||
}, []);
|
||||
const [activeSessions, setActiveSessions] = useState<any[]>([]);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
@@ -39,7 +46,7 @@ export function useSessions() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll every 10s when Active tab is selected
|
||||
// Poll every 3s when Active tab is selected
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'active') return;
|
||||
fetchActiveSessions();
|
||||
@@ -86,11 +93,28 @@ export function useSessions() {
|
||||
setSelectedProjectDir(dir);
|
||||
if (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 {
|
||||
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 {
|
||||
sessions: filteredSessions,
|
||||
projects,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { AggregatedTask, TaskSnapshot } from '../../server/stores/task-aggregator';
|
||||
|
||||
export type { AggregatedTask, TaskSnapshot };
|
||||
|
||||
const EMPTY_SNAPSHOT: TaskSnapshot = { tasks: [], currentRound: [], completed: 0, total: 0, round: 0, hasHistory: false };
|
||||
|
||||
export function useTaskState() {
|
||||
const [taskSnapshot, setTaskSnapshot] = useState<TaskSnapshot>(EMPTY_SNAPSHOT);
|
||||
|
||||
const handleTaskState = useCallback((msg: TaskSnapshot) => {
|
||||
setTaskSnapshot(msg);
|
||||
}, []);
|
||||
|
||||
const resetTasks = useCallback(() => {
|
||||
setTaskSnapshot(EMPTY_SNAPSHOT);
|
||||
}, []);
|
||||
|
||||
return { taskSnapshot, handleTaskState, resetTasks };
|
||||
}
|
||||
@@ -21,6 +21,10 @@
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
html, body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
@@ -28,6 +32,7 @@ body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -109,10 +109,10 @@ export const api = {
|
||||
vapidPublicKey: () =>
|
||||
request<{ publicKey: string }>('/api/push/vapid-public-key'),
|
||||
|
||||
pushSubscribe: (subscription: PushSubscriptionJSON) =>
|
||||
pushSubscribe: (subscription: PushSubscriptionJSON, deviceId: string) =>
|
||||
request<{ ok: boolean }>('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ subscription }),
|
||||
body: JSON.stringify({ subscription, deviceId }),
|
||||
}),
|
||||
|
||||
pushUnsubscribe: (endpoint: string) =>
|
||||
|
||||
@@ -5,6 +5,8 @@ export const STORAGE = {
|
||||
PROJECT_DIR: 'clawtap:projectDir',
|
||||
DRAFT: 'clawtap:draft',
|
||||
INSTALL_DISMISSED: 'clawtap:install-dismissed',
|
||||
SESSIONS_TAB: 'clawtap:sessionsTab',
|
||||
PUSH_PROMPTED: 'clawtap:push-prompted',
|
||||
adapterPrefs: (id: string) => `clawtap:adapterPrefs:${id}` as const,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export const WS = {
|
||||
SET_PERMISSION_MODE: 'set-permission-mode',
|
||||
SET_MODEL: 'set-model',
|
||||
PLAN_RESPONSE: 'plan-response',
|
||||
PAGE_VISIBILITY: 'page-visibility',
|
||||
APP_PONG: 'app-pong',
|
||||
// Server → Client
|
||||
SESSION_CREATED: 'session-created',
|
||||
TEXT_DELTA: 'text-delta',
|
||||
@@ -37,6 +39,10 @@ export const WS = {
|
||||
// Cross-AI Review
|
||||
REVIEW_STARTED: 'review-started',
|
||||
REVIEW_ENDED: 'review-ended',
|
||||
// Task Progress
|
||||
TASK_STATE: 'task-state',
|
||||
// Push notification coordination
|
||||
APP_PING: 'app-ping',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
+62
-1
@@ -5,6 +5,9 @@ export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecti
|
||||
type MessageHandler = (msg: any) => void;
|
||||
type StatusHandler = (status: WsStatus) => void;
|
||||
|
||||
/** Minimum hidden duration (ms) before forcing reconnect on visibility change. */
|
||||
const VISIBILITY_RECONNECT_THRESHOLD = 3000;
|
||||
|
||||
export class WsClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
@@ -15,6 +18,8 @@ export class WsClient {
|
||||
private shouldReconnect = true;
|
||||
private activeSessionId: string | null = null;
|
||||
private activeAdapter: string | null = null;
|
||||
private visibilityHandler: (() => void) | null = null;
|
||||
private hiddenSince: number | null = null;
|
||||
|
||||
constructor(token: string, onMessage: MessageHandler, onStatus: StatusHandler) {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
@@ -36,6 +41,8 @@ export class WsClient {
|
||||
if (this.activeSessionId) {
|
||||
this.send({ type: WS.RECONNECT, sessionId: this.activeSessionId, adapter: this.activeAdapter });
|
||||
}
|
||||
// Tell server our current visibility state immediately on (re)connect
|
||||
this.send({ type: WS.PAGE_VISIBILITY, visible: document.visibilityState === 'visible' });
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
@@ -45,8 +52,18 @@ export class WsClient {
|
||||
this.activeSessionId = msg.sessionId;
|
||||
if (msg.adapter) this.activeAdapter = msg.adapter;
|
||||
}
|
||||
// Respond to server app-ping only when page is visible.
|
||||
// If hidden, no pong → server fires push after 2s timeout.
|
||||
if (msg.type === WS.APP_PING) {
|
||||
if (document.visibilityState === 'visible') {
|
||||
this.send({ type: WS.APP_PONG, sessionId: msg.sessionId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.onMessage(msg);
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
console.error('[ws] Failed to parse message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
@@ -61,6 +78,8 @@ export class WsClient {
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close();
|
||||
};
|
||||
|
||||
this._startVisibilityWatch();
|
||||
}
|
||||
|
||||
send(msg: any) {
|
||||
@@ -74,9 +93,51 @@ export class WsClient {
|
||||
this.activeAdapter = adapter || null;
|
||||
}
|
||||
|
||||
/** Force close and reconnect immediately (reset backoff). */
|
||||
forceReconnect() {
|
||||
if (!this.shouldReconnect) return;
|
||||
this.reconnectDelay = 1000;
|
||||
this.hiddenSince = null;
|
||||
const old = this.ws;
|
||||
this.ws = null;
|
||||
if (old) {
|
||||
old.onclose = null;
|
||||
old.onerror = null;
|
||||
old.onmessage = null;
|
||||
old.close();
|
||||
}
|
||||
this.connect();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
this._stopVisibilityWatch();
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
private _startVisibilityWatch() {
|
||||
if (this.visibilityHandler) return;
|
||||
this.visibilityHandler = () => {
|
||||
const visible = document.visibilityState === 'visible';
|
||||
this.send({ type: WS.PAGE_VISIBILITY, visible });
|
||||
if (!visible) {
|
||||
this.hiddenSince = Date.now();
|
||||
} else if (this.shouldReconnect) {
|
||||
const elapsed = this.hiddenSince ? Date.now() - this.hiddenSince : 0;
|
||||
this.hiddenSince = null;
|
||||
if (elapsed >= VISIBILITY_RECONNECT_THRESHOLD) {
|
||||
this.forceReconnect();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', this.visibilityHandler);
|
||||
}
|
||||
|
||||
private _stopVisibilityWatch() {
|
||||
if (this.visibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
||||
this.visibilityHandler = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ declare const self: ServiceWorkerGlobalScope;
|
||||
// Precache static assets (injected by vite-plugin-pwa at build time)
|
||||
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.
|
||||
// Exclude volatile real-time endpoints (messages, active sessions, reviews).
|
||||
registerRoute(
|
||||
@@ -52,7 +58,7 @@ self.addEventListener('push', (event) => {
|
||||
return self.registration.showNotification(payload.title, {
|
||||
body: payload.body,
|
||||
icon: '/pwa-192x192.png',
|
||||
badge: '/pwa-192x192.png',
|
||||
badge: '/badge-96x96.png',
|
||||
tag: payload.tag || payload.data?.sessionId || 'default',
|
||||
data: payload.data,
|
||||
});
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVICE_NAME="clawtap.service"
|
||||
UNIT_DIR="$HOME/.config/systemd/user"
|
||||
ENV_FILE="$HOME/.clawtap/env"
|
||||
UNIT_FILE="$UNIT_DIR/$SERVICE_NAME"
|
||||
|
||||
# --- Install systemd unit if missing ---
|
||||
if [ ! -f "$UNIT_FILE" ]; then
|
||||
echo "Installing $SERVICE_NAME..."
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "ERROR: $ENV_FILE not found."
|
||||
echo "Create it with at minimum:"
|
||||
echo " CLAWTAP_PASSWORD=your-password-here"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$UNIT_DIR"
|
||||
cat > "$UNIT_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=ClawTap — mobile UI for AI coding sessions
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$REPO_DIR
|
||||
Environment=HOME=$HOME
|
||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||
EnvironmentFile=$ENV_FILE
|
||||
ExecStart=$REPO_DIR/node_modules/.bin/tsx $REPO_DIR/server/index.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:$HOME/.clawtap/server.log
|
||||
StandardError=append:$HOME/.clawtap/server.log
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable "$SERVICE_NAME"
|
||||
echo "Service installed and enabled."
|
||||
fi
|
||||
|
||||
# --- Build frontend ---
|
||||
echo "Building frontend..."
|
||||
cd "$REPO_DIR"
|
||||
npm run build
|
||||
|
||||
# --- Install CLI binary ---
|
||||
echo "Installing CLI binary..."
|
||||
sudo cp "$REPO_DIR/bin/clawtap" /usr/bin/clawtap
|
||||
|
||||
# --- Restart ---
|
||||
echo "Restarting $SERVICE_NAME..."
|
||||
systemctl --user restart "$SERVICE_NAME"
|
||||
|
||||
echo "Status:"
|
||||
systemctl --user status "$SERVICE_NAME" --no-pager
|
||||
+2
-1
@@ -39,7 +39,7 @@ export default defineConfig({
|
||||
categories: ['developer-tools', 'productivity'],
|
||||
},
|
||||
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'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -49,6 +49,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://localhost:3456',
|
||||
|
||||
Reference in New Issue
Block a user