feat: ClawTap v0.1.0 — initial release

Multi-adapter mobile UI for AI coding assistants.
Supports Claude Code, Codex CLI, and Gemini CLI through one interface.

Features:
- Real-time bidirectional sync via tmux + WebSocket
- Cross-AI review (send one AI's output to another for review)
- Multi-review tabs with minimize/expand
- Push notifications (PWA) with smart session-aware filtering
- Three-channel event system (hooks, file watcher, pane monitor)
- Voice input, image paste, draft persistence
- Terminal-native design (JetBrains Mono, dark theme, pixel art claw)
- CLI with --adapter flag on every command
- Zero-overhead fire-and-forget hooks
This commit is contained in:
kuannnn
2026-03-18 10:24:45 +08:00
commit 42861ea7fa
151 changed files with 33897 additions and 0 deletions
+569
View File
@@ -0,0 +1,569 @@
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { STORAGE } from '../lib/storage-keys';
import { WsClient, type WsStatus } from '../lib/ws';
import { WS } from '../lib/ws-types';
import { api } from '../lib/api';
import { patchAdapterPrefs, loadAdapterPrefs } from '../lib/adapter-prefs';
import { stripMarker } from '@/lib/content-utils';
export type ChatMessage = {
id?: string;
role: 'user' | 'assistant' | 'plan' | 'interrupted';
content: any[];
};
export type PermissionRequest = {
requestId: string;
toolName: string;
input: any;
decisionReason?: string;
};
export type ToolStatus = {
toolUseId: string;
toolName: string;
input: any;
status: 'running' | 'success' | 'error' | 'interrupted';
result?: any;
parentToolUseId?: string;
};
/** Check if message content contains an interrupt marker */
function isInterruptContent(content: any): boolean {
const arr = Array.isArray(content) ? content : typeof content === 'string' ? [content] : [];
return arr.some((b: any) => {
const text = typeof b === 'string' ? b : (b.text || '');
return text.includes('[Request interrupted by user');
});
}
/** Mark all running tools with a terminal status */
function markToolsAs(status: 'success' | 'interrupted') {
return (prev: Map<string, ToolStatus>): Map<string, ToolStatus> => {
let changed = false;
for (const [, tool] of prev) {
if (tool.status === 'running') { changed = true; break; }
}
if (!changed) return prev;
const next = new Map(prev);
for (const [id, tool] of next) {
if (tool.status === 'running') next.set(id, { ...tool, status });
}
return next;
};
}
const SESSION_ERROR_LABELS: Record<string, string> = {
rate_limit: 'Rate limited — please wait',
authentication_failed: 'Authentication failed',
billing_error: 'Billing error',
server_error: 'Server error',
max_output_tokens: 'Max output tokens reached',
};
function convertMessages(msgs: any[]): ChatMessage[] {
const converted: ChatMessage[] = [];
for (const msg of msgs) {
if (msg.role === 'user') {
const content = typeof msg.content === 'string'
? [{ type: 'text', text: stripMarker(msg.content) }]
: (msg.content || []).map((b: any) =>
b.type === 'text' ? { ...b, text: stripMarker(b.text || '') } : b
);
if (isInterruptContent(content)) {
converted.push({ id: msg.id, role: 'interrupted', content: [] });
continue;
}
converted.push({ id: msg.id, role: 'user', content });
} else if (msg.role === 'assistant') {
converted.push({ id: msg.id, role: 'assistant', content: msg.content || [] });
} else if (msg.role === 'plan') {
converted.push({ id: msg.id, role: 'plan', content: [{ type: 'text', text: msg.content }] });
}
}
return converted;
}
export interface ReviewInfo {
reviewId: string;
childSessionId: string;
childCliSessionId: string;
childAdapter: string;
anchorMessageId?: string;
reviewTitle?: string;
}
export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?: string, initialPrompt?: string) {
// --- State ---
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState<string>('');
const [thinkingStatus, setThinkingStatus] = useState<{ text: string; detail: string | null } | null>(null);
// Tool state precedence (highest → lowest):
// 1. abort() → 'interrupted' (immediate, overrides everything)
// 2. WS.TOOL_DONE (hook) → 'success'/'error' (instant from PostToolUse)
// 3. WS.TOOL_UPDATES (JSONL) → fallback, only updates tools still in 'running'
// 4. WS.TURN_COMPLETE → marks remaining 'running' tools as 'success' (cleanup)
const [toolStatuses, setToolStatuses] = useState<Map<string, ToolStatus>>(new Map());
const [streaming, setStreaming] = useState(false);
const [pendingResponse, setPendingResponse] = useState(false);
const [wsStatus, setWsStatus] = useState<WsStatus>('disconnected');
const [sessionId, setSessionId] = useState<string | null>(initialSessionId || null);
const [permissionRequest, setPermissionRequest] = useState<PermissionRequest | null>(null);
// True when the most recent turn was interrupted — used for input placeholder
const [interrupted, setInterrupted] = useState(false);
// Resolve adapter + prefs once, share across state initializers
const resolvedAdapter = initialAdapter || localStorage.getItem(STORAGE.ADAPTER) || 'claude';
const initialPrefs = loadAdapterPrefs(resolvedAdapter);
const [model, setModel] = useState<string>(initialPrefs.model || '');
const [permissionMode, setPermissionMode] = useState<string>(initialPrefs.permissionMode || 'default');
const [effort, setEffort] = useState<string>(initialPrefs.effort || 'high');
const [sessionStatus, setSessionStatus] = useState<{
contextPercent: number | null;
model: string | null;
cost: number | null;
} | null>(null);
const [queuedMessage, setQueuedMessage] = useState<string | null>(null);
const [selectedAdapter, setSelectedAdapter] = useState<string>(resolvedAdapter);
const [adapterConfig, setAdapterConfig] = useState<{
models: { value: string; label: string; contextWindow: number }[];
permissionModes: { value: string; label: string }[];
effortLevels: { value: string; label: string }[];
effortLabel: string;
capabilities?: {
supportsPermissionModes: boolean;
permissionModeType?: 'cycle' | 'toggle';
};
} | null>(null);
const [activeReviews, setActiveReviews] = useState<ReviewInfo[]>([]);
const [activeReviewPanel, setActiveReviewPanel] = useState<'expanded' | 'minimized'>('expanded');
const [historyReview, setHistoryReview] = useState<any>(null);
const queuedRef = useRef<string | null>(null);
const streamingRef = useRef(false);
const interruptedRef = useRef(false);
const wsRef = useRef<WsClient | null>(null);
const actualSendRef = useRef<(text: string) => void>(() => {});
const clientIdRef = useRef<string | null>(null);
const selectedAdapterRef = useRef<string>(selectedAdapter);
selectedAdapterRef.current = selectedAdapter;
streamingRef.current = streaming;
interruptedRef.current = interrupted;
queuedRef.current = queuedMessage;
// --- Drain queued message ---
const drainQueue = useCallback(() => {
if (queuedRef.current) {
const queued = queuedRef.current;
queuedRef.current = null;
setQueuedMessage(null);
setTimeout(() => actualSendRef.current(queued), 100);
}
}, []);
// --- WebSocket Message Handler ---
const handleWsMessage = useCallback((msg: any) => {
switch (msg.type) {
case WS.SESSION_CREATED:
setSessionId(msg.sessionId);
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
break;
case WS.CLIENT_ID:
clientIdRef.current = msg.clientId;
break;
// Pane monitor: streaming text preview (ephemeral, replaces previous)
// Don't set streaming=true here — it's already true from sending the query.
// Setting it here would re-enable streaming after turn-complete if pane still has text.
case WS.TEXT_DELTA:
if (streamingRef.current) {
setStreamingText(msg.text || '');
}
break;
// Pane monitor: thinking indicator
case WS.THINKING:
if (streamingRef.current) {
setThinkingStatus({ text: msg.text, detail: msg.detail || null });
}
break;
// Hook: tool execution started
case WS.TOOL_START:
// Don't add tools after abort — they're stale hook events
if (!streamingRef.current) break;
setToolStatuses(prev => {
const next = new Map(prev);
next.set(msg.toolId, {
toolUseId: msg.toolId,
toolName: msg.toolName,
input: msg.input,
status: 'running',
});
return next;
});
break;
// Hook: tool execution finished (success via PostToolUse, failure via PostToolUseFailure)
case WS.TOOL_DONE:
setToolStatuses(prev => {
const existing = prev.get(msg.toolId);
if (!existing || existing.status !== 'running') return prev;
const next = new Map(prev);
next.set(msg.toolId, {
...existing,
status: msg.result?.is_interrupt ? 'interrupted' : msg.result?.is_error ? 'error' : 'success',
result: msg.result,
});
return next;
});
// AskUserQuestion completed — dismiss overlay on all clients
// Guard: only dismiss if the current overlay IS an AskUserQuestion
// (a new PermissionRequest may have arrived between answer and TOOL_DONE)
if (msg.toolName === 'AskUserQuestion') {
setPermissionRequest((prev) =>
prev?.toolName === 'AskUserQuestion' ? null : prev
);
}
break;
// JSONL watcher: complete messages (single source of truth)
case WS.MESSAGE_COMPLETE:
if (msg.messages && Array.isArray(msg.messages)) {
// Skip user messages — already added locally when sent
// But detect interrupt markers and convert to { role: 'interrupted' }
const converted: ChatMessage[] = [];
for (const m of msg.messages) {
if (m.role === 'user') {
if (isInterruptContent(m.content)) {
converted.push({ role: 'interrupted', content: [] });
setInterrupted(true);
continue;
}
// Skip only if this client sent it (already shown via optimistic UI)
if (m.senderClientId && m.senderClientId === clientIdRef.current) continue;
}
const c = convertMessages([m]);
converted.push(...c);
}
if (converted.length > 0) {
setMessages(prev => [...prev, ...converted]);
setStreamingText('');
setThinkingStatus(null);
if (converted.some(m => m.role === 'assistant')) setPendingResponse(false);
}
}
break;
// JSONL watcher: tool status updates from transcript parser
case WS.TOOL_UPDATES:
if (msg.tools) {
setToolStatuses(prev => {
let changed = false;
const next = new Map(prev);
for (const [id, tool] of Object.entries(msg.tools as Record<string, any>)) {
const existing = next.get(id);
// Don't overwrite terminal states (interrupted/success/error) with 'running'
if (existing && existing.status !== 'running') continue;
// Only update existing tools (registered via TOOL_START) — don't add unknown
// 'running' tools from stale watcher data (old turns re-parsed by JSONL watcher)
if (!existing && tool.status === 'running') continue;
next.set(id, { ...tool, toolName: tool.toolName || tool.name });
changed = true;
}
return changed ? next : prev;
});
}
break;
// Stop hook: turn complete, ready for next input
case WS.TURN_COMPLETE:
setStreaming(false);
setPendingResponse(false);
setStreamingText('');
setThinkingStatus(null);
setPermissionRequest(null);
// Mark remaining running tools: if user interrupted → 'interrupted', otherwise → 'success'
setToolStatuses(markToolsAs(interruptedRef.current ? 'interrupted' : 'success'));
streamingRef.current = false;
drainQueue();
break;
case WS.REVIEW_STARTED:
setActiveReviews(prev => {
if (prev.some(r => r.reviewId === msg.reviewId)) return prev;
return [...prev, {
reviewId: msg.reviewId,
childSessionId: msg.childSessionId,
childCliSessionId: msg.childCliSessionId,
childAdapter: msg.childAdapter,
anchorMessageId: msg.anchorMessageId,
reviewTitle: msg.reviewTitle,
}];
});
setActiveReviewPanel('expanded');
break;
case WS.REVIEW_ENDED:
setActiveReviews(prev => prev.filter(r => r.reviewId !== msg.reviewId));
break;
// Hook: permission request
case WS.PERMISSION_REQUEST:
setPermissionRequest({
requestId: msg.requestId,
toolName: msg.toolName,
input: msg.input,
});
break;
// Another client answered the permission request — dismiss overlay
case WS.PERMISSION_DISMISSED:
setPermissionRequest((prev) =>
prev?.requestId === msg.requestId ? null : prev
);
break;
case WS.SESSION_STATE:
if (msg.streaming) {
if (!streamingRef.current) {
setInterrupted(false);
}
setStreaming(true);
setPendingResponse(true);
streamingRef.current = true;
}
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);
break;
case WS.STATUS_UPDATE:
setSessionStatus(prev => {
if (prev &&
prev.contextPercent === msg.contextPercent &&
prev.model === msg.model &&
prev.cost === msg.cost) return prev;
return { contextPercent: msg.contextPercent, model: msg.model, cost: msg.cost };
});
break;
case WS.MODE_UPDATED:
setPermissionMode(msg.mode);
patchAdapterPrefs(selectedAdapterRef.current, { permissionMode: msg.mode });
if (msg.mode === 'bypassPermissions' || msg.mode === 'plan') {
setPermissionRequest(null);
}
break;
case WS.COMPACTING:
setThinkingStatus({ text: 'Compacting context...', detail: '' });
break;
case WS.COMPACT_DONE:
setThinkingStatus(null);
break;
case WS.SESSION_ERROR: {
const errorMsg = SESSION_ERROR_LABELS[msg.errorType] || msg.errorDetails || msg.errorType;
setMessages(prev => [...prev, {
role: 'assistant',
content: [{ type: 'text', text: `⚠️ ${errorMsg}` }],
}]);
break;
}
case WS.SESSION_ENDED:
setStreaming(false);
setPendingResponse(false);
streamingRef.current = false;
break;
case WS.ERROR:
setStreaming(false);
setPendingResponse(false);
streamingRef.current = false;
console.error('Server error:', msg.error);
break;
}
}, [drainQueue]);
// --- WebSocket Connection ---
useEffect(() => {
const token = localStorage.getItem(STORAGE.TOKEN);
if (!token) return;
const client = new WsClient(token, handleWsMessage, setWsStatus);
wsRef.current = client;
if (initialSessionId) {
client.setActiveSession(initialSessionId, selectedAdapter);
}
client.connect();
return () => {
client.disconnect();
wsRef.current = null;
clientIdRef.current = null;
};
}, [handleWsMessage, initialSessionId]);
// Auto-send initialPrompt when WS connects (for new sessions only)
const initialPromptSent = useRef(false);
useEffect(() => {
if (initialPrompt && !initialSessionId && !initialPromptSent.current && wsStatus === 'connected') {
initialPromptSent.current = true;
actualSendRef.current(initialPrompt);
}
}, [initialPrompt, initialSessionId, wsStatus]);
// Keep WsClient's activeAdapter in sync so reconnect sends correct adapter hint
useEffect(() => {
if (wsRef.current && sessionId) {
wsRef.current.setActiveSession(sessionId, selectedAdapter);
}
}, [sessionId, selectedAdapter]);
// --- Fetch adapter config (models, permission modes) ---
useEffect(() => {
api.adapterConfig(selectedAdapter).then(setAdapterConfig).catch(console.error);
}, [selectedAdapter]);
// --- Send Message ---
const actualSend = useCallback((text: string) => {
if (!text.trim() || !wsRef.current) return;
streamingRef.current = true;
setMessages(prev => [
...prev,
{ role: 'user', content: [{ type: 'text', text }] },
]);
setStreaming(true);
setPendingResponse(true);
setToolStatuses(new Map()); // Clear tools for new turn
setInterrupted(false); // Reset placeholder
wsRef.current.send({
type: WS.QUERY,
prompt: text,
options: {
adapter: selectedAdapter,
cwd: cwd || undefined,
model,
sessionId: sessionId || undefined,
permissionMode,
effort,
},
});
}, [cwd, model, sessionId, permissionMode, effort, selectedAdapter]);
actualSendRef.current = actualSend;
const sendMessage = useCallback((text: string) => {
if (!text.trim()) return;
if (streamingRef.current) {
queuedRef.current = text;
setQueuedMessage(text);
} else {
actualSend(text);
}
}, [actualSend]);
const clearQueuedMessage = useCallback(() => {
queuedRef.current = null;
setQueuedMessage(null);
}, []);
// --- Permission / Question Response ---
const respondPermission = useCallback((requestId: string, behavior: 'allow' | 'allow_session' | 'deny', message?: string) => {
wsRef.current?.send({
type: WS.PERMISSION_RESPONSE,
requestId,
behavior,
message,
});
setPermissionRequest(null);
}, []);
const respondAsk = useCallback((requestId: string, response: string) => {
wsRef.current?.send({
type: WS.ASK_RESPONSE,
requestId,
response,
});
setPermissionRequest(null);
}, []);
// --- Plan Response ---
const respondPlan = useCallback((optionIndex: number, text?: string) => {
// Enter streaming mode — CLI will start executing tools after approval
setStreaming(true);
setPendingResponse(true);
streamingRef.current = true;
setToolStatuses(new Map());
wsRef.current?.send({
type: WS.PLAN_RESPONSE,
sessionId,
optionIndex,
text,
});
}, [sessionId]);
// --- Abort ---
const abort = useCallback(() => {
wsRef.current?.send({ type: WS.ABORT, sessionId });
setStreaming(false);
setPendingResponse(false);
setStreamingText('');
setThinkingStatus(null);
setPermissionRequest(null);
setInterrupted(true); // Immediately mark as interrupted for tool card fallback
setToolStatuses(markToolsAs('interrupted'));
}, [sessionId]);
// --- Settings ---
const updateModel = useCallback((m: string) => {
setModel(m);
patchAdapterPrefs(selectedAdapter, { model: m });
if (sessionId) {
wsRef.current?.send({ type: WS.SET_MODEL, sessionId, model: m });
}
}, [sessionId, selectedAdapter]);
const updateAdapter = useCallback((adapter: string) => {
setSelectedAdapter(adapter);
localStorage.setItem(STORAGE.ADAPTER, adapter);
const prefs = loadAdapterPrefs(adapter);
if (prefs.model) setModel(prefs.model);
if (prefs.permissionMode) setPermissionMode(prefs.permissionMode);
if (prefs.effort) setEffort(prefs.effort);
}, []);
const updatePermissionMode = useCallback((m: string) => {
setPermissionMode(m);
patchAdapterPrefs(selectedAdapter, { permissionMode: m });
if (sessionId) {
wsRef.current?.send({ type: WS.SET_PERMISSION_MODE, sessionId, mode: m });
}
}, [sessionId, selectedAdapter]);
const liveStatus = useMemo(() => {
if (thinkingStatus) return { type: 'thinking' as const, text: thinkingStatus.detail ? `${thinkingStatus.text} (${thinkingStatus.detail})` : thinkingStatus.text };
if (streamingText) return { type: 'streaming' as const, text: streamingText };
return null;
}, [thinkingStatus, streamingText]);
return {
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
interrupted, sessionStatus, adapterConfig, selectedAdapter,
permissionRequest, model, permissionMode, effort,
queuedMessage, clearQueuedMessage,
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
historyReview, setHistoryReview,
sendMessage, respondPermission, respondAsk, respondPlan, abort,
updateModel, updatePermissionMode, updateAdapter,
};
}