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
+296
View File
@@ -0,0 +1,296 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { STORAGE } from './lib/storage-keys';
import { isAuthenticated, clearToken } from './lib/api';
import { LoginView } from './components/LoginView';
import { SessionsView } from './components/SessionsView';
import { ChatView } from './components/ChatView';
import { SettingsView } from './components/SettingsView';
import { NewChatView } from './components/NewChatView';
import { OfflineView } from './components/OfflineView';
import { LoadingAnimation } from './components/ui/LoadingAnimation';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
type View =
| { name: 'sessions' }
| { name: 'newchat'; cwd: string }
| { name: 'chat'; sessionId?: string; cwd?: string; initialPrompt?: string; adapter?: string }
| { name: 'settings' };
function loadView(): View {
try {
const saved = sessionStorage.getItem('currentView');
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.name === 'chat' && parsed.sessionId) return parsed;
if (parsed.name === 'newchat' && parsed.cwd) return parsed;
}
} catch {}
return { name: 'sessions' };
}
function persistView(view: View) {
sessionStorage.setItem('currentView', JSON.stringify(view));
}
function navigateTo(view: View) {
persistView(view);
const url = view.name === 'chat' && view.sessionId
? `/?view=chat&session=${view.sessionId}`
: view.name === 'settings'
? '/?view=settings'
: '/';
window.history.pushState({ view }, '', url);
}
export function App() {
const [authed, setAuthed] = useState(isAuthenticated());
const [view, setView] = useState<View>(loadView);
const [serverOnline, setServerOnline] = useState<boolean | null>(null);
const [deviceOnline, setDeviceOnline] = useState(navigator.onLine);
const consecutiveFails = useRef(0);
const initialized = useRef(false);
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [installDismissed, setInstallDismissed] = useState(
() => localStorage.getItem(STORAGE.INSTALL_DISMISSED) === 'true'
);
const dismissInstall = useCallback(() => {
setInstallPrompt(null);
setInstallDismissed(true);
localStorage.setItem(STORAGE.INSTALL_DISMISSED, 'true');
}, []);
const [swUpdateAvailable, setSwUpdateAvailable] = useState(false);
const handleInstall = useCallback(async () => {
if (!installPrompt) return;
installPrompt.prompt();
const result = await installPrompt.userChoice;
if (result.outcome === 'accepted') dismissInstall();
}, [installPrompt, dismissInstall]);
const handleLogin = useCallback(() => setAuthed(true), []);
const handleLogout = useCallback(() => {
clearToken();
sessionStorage.removeItem('currentView');
setAuthed(false);
}, []);
const openChat = useCallback((sessionId?: string, cwd?: string, adapter?: string) => {
if (!sessionId && cwd) {
const v: View = { name: 'newchat', cwd };
navigateTo(v);
setView(v);
} else {
const v: View = { name: 'chat', sessionId, cwd, adapter };
navigateTo(v);
setView(v);
}
}, []);
// PWA: Android install prompt
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handler);
window.addEventListener('appinstalled', dismissInstall);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
window.removeEventListener('appinstalled', dismissInstall);
};
}, [dismissInstall]);
// PWA: Service worker update notification
useEffect(() => {
const handleControllerChange = () => setSwUpdateAvailable(true);
navigator.serviceWorker?.addEventListener('controllerchange', handleControllerChange);
return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange);
}, []);
// PWA: Clear app badge on focus
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === 'visible') {
navigator.clearAppBadge?.();
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
// Handle browser back/forward navigation
useEffect(() => {
const handlePopState = (event: PopStateEvent) => {
if (event.state?.view) {
setView(event.state.view);
persistView(event.state.view);
} else {
setView({ name: 'sessions' });
persistView({ name: 'sessions' });
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// Set initial history state (replaceState, not pushState, to avoid double entry)
useEffect(() => {
window.history.replaceState({ view }, '', window.location.pathname + window.location.search);
}, []);
const backToSessions = useCallback(() => {
const v: View = { name: 'sessions' };
navigateTo(v);
setView(v);
}, []);
// Layer 1: Device network (instant)
useEffect(() => {
const goOnline = () => setDeviceOnline(true);
const goOffline = () => setDeviceOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []);
// Layer 2: Server health check
const checkHealth = useCallback(() => {
fetch('/health', { signal: AbortSignal.timeout(5000) })
.then(res => {
if (res.ok) {
consecutiveFails.current = 0;
setServerOnline(true);
} else {
consecutiveFails.current++;
if (!initialized.current || consecutiveFails.current >= 2) setServerOnline(false);
}
})
.catch(() => {
consecutiveFails.current++;
if (!initialized.current || consecutiveFails.current >= 2) setServerOnline(false);
});
}, []);
useEffect(() => {
checkHealth();
initialized.current = true;
const interval = setInterval(checkHealth, 15000);
return () => clearInterval(interval);
}, [checkHealth]);
// Handle OPEN_SESSION messages from service worker (push notification clicks)
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.data?.type === 'OPEN_SESSION' && event.data.sessionId) {
openChat(event.data.sessionId);
}
};
navigator.serviceWorker?.addEventListener('message', handler);
return () => navigator.serviceWorker?.removeEventListener('message', handler);
}, [openChat]);
// Handle URL parameters (?session= from notification click, ?action=newchat from PWA shortcut)
const urlParamsHandled = useRef(false);
useEffect(() => {
if (urlParamsHandled.current || !authed) return;
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session');
const action = params.get('action');
if (sessionId) {
urlParamsHandled.current = true;
openChat(sessionId);
window.history.replaceState({}, '', '/');
} else if (action === 'newchat') {
urlParamsHandled.current = true;
window.history.replaceState({}, '', '/');
}
}, [authed, openChat]);
const startChat = useCallback((options: { adapter: string; model: string; permissionMode: string; effort: string; prompt: string }) => {
// NewChatView.handleSend already saved adapter prefs via saveAdapterPrefs — just set the active adapter
localStorage.setItem(STORAGE.ADAPTER, options.adapter);
// 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);
setView(v);
}, [view]);
const isOffline = !deviceOnline || serverOnline === false;
// 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>
);
}
// Offline screen
if (isOffline) {
return <OfflineView onRetry={checkHealth} />;
}
if (!authed) {
return <LoginView onLogin={handleLogin} />;
}
if (view.name === 'newchat') {
return (
<NewChatView
cwd={view.cwd}
onStartChat={startChat}
onBack={backToSessions}
/>
);
}
if (view.name === 'settings') {
return <SettingsView onBack={() => setView({ name: 'sessions' })} />;
}
if (view.name === 'chat') {
return (
<ChatView
sessionId={view.sessionId}
cwd={view.cwd}
initialPrompt={view.initialPrompt}
adapter={view.adapter}
onBack={backToSessions}
/>
);
}
return (
<>
<SessionsView
onOpenChat={openChat}
onLogout={handleLogout}
onOpenSettings={() => setView({ name: 'settings' })}
installPrompt={!installDismissed ? installPrompt : null}
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>
)}
</>
);
}
+73
View File
@@ -0,0 +1,73 @@
import React from 'react';
import { getBrand } from '@/lib/adapter-brands';
interface AdapterIconProps {
adapterId: string;
size?: number;
className?: string;
}
/** Anthropic "A" lettermark — official brand icon from thesvg.org */
function ClaudeIcon({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
fillRule="evenodd"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" />
</svg>
);
}
/** Google Gemini sparkle — official brand icon from thesvg.org */
function GeminiIcon({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" />
</svg>
);
}
/** OpenAI knot/flower logo — official brand icon from thesvg.org, used for Codex */
function CodexIcon({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 256 260"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" />
</svg>
);
}
const ICON_MAP: Record<string, React.FC<{ size: number }>> = {
claude: ClaudeIcon,
codex: CodexIcon,
gemini: GeminiIcon,
};
export function AdapterIcon({ adapterId, size = 24, className }: AdapterIconProps) {
const brand = getBrand(adapterId);
const Icon = ICON_MAP[brand.iconType] || ClaudeIcon;
return (
<span className={className} style={{ color: brand.color, display: 'inline-flex' }}>
<Icon size={size} />
</span>
);
}
export default AdapterIcon;
+97
View File
@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { api } from '@/lib/api';
import { loadAdapterPrefs, patchAdapterPrefs } from '@/lib/adapter-prefs';
import { getBrand } from '@/lib/adapter-brands';
import { AdapterIcon } from './AdapterIcon';
import type { AdapterConfig } from '@/types/adapter';
export function AdapterSettingsSection({ adapter, onBack }: { adapter: string; onBack: () => void }) {
const [config, setConfig] = useState<AdapterConfig | null>(null);
const [prefs, setPrefs] = useState(() => loadAdapterPrefs(adapter));
const [error, setError] = useState<string | null>(null);
const brand = getBrand(adapter);
useEffect(() => {
let cancelled = false;
api.adapterConfig(adapter)
.then((cfg) => {
if (!cancelled) setConfig(cfg);
})
.catch((err) => {
if (!cancelled) setError(err.message ?? 'Failed to load config');
});
return () => { cancelled = true; };
}, [adapter]);
function handleChange(field: 'model' | 'permissionMode' | 'effort', value: string) {
const updated = { ...prefs, [field]: value };
setPrefs(updated);
patchAdapterPrefs(adapter, { [field]: value });
}
const selectClass = 'bg-surface border border-border rounded-md text-text px-3 py-2 w-full appearance-none outline-none focus:border-accent font-mono';
const labelClass = 'text-text-dim text-xs uppercase tracking-wider mb-1.5 font-mono';
return (
<div className="flex flex-col h-full bg-bg">
<div className="flex items-center px-4 py-3 border-b border-border gap-2">
<button onClick={onBack} className="text-text-dim hover:text-text"><ChevronLeft className="w-5 h-5" /></button>
<AdapterIcon adapterId={adapter} size={20} />
<span className="font-medium text-text font-mono tracking-wide">{brand.displayName} Settings</span>
</div>
{error && (
<div className="px-4 py-3 text-red-400 text-sm">{error}</div>
)}
{!config && !error && (
<div className="flex-1 flex items-center justify-center text-text-dim text-sm">Loading</div>
)}
{config && (
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
<div>
<label className={labelClass}>Model</label>
<select
className={selectClass}
value={prefs.model}
onChange={(e) => handleChange('model', e.target.value)}
>
{config.models.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Permission Mode</label>
<select
className={selectClass}
value={prefs.permissionMode}
onChange={(e) => handleChange('permissionMode', e.target.value)}
>
{config.permissionModes.map((pm) => (
<option key={pm.value} value={pm.value}>{pm.label}</option>
))}
</select>
</div>
<div>
<label className={labelClass}>{config.effortLabel}</label>
<select
className={selectClass}
value={prefs.effort}
onChange={(e) => handleChange('effort', e.target.value)}
>
{config.effortLevels.map((el) => (
<option key={el.value} value={el.value}>{el.label}</option>
))}
</select>
</div>
</div>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { ADAPTER_BRANDS, type AdapterBrand } from '@/lib/adapter-brands';
const TABS: { id: string; label: string; brand: AdapterBrand | null }[] = [
{ id: 'all', label: 'All', brand: null },
...Object.values(ADAPTER_BRANDS).map(b => ({ id: b.id, label: b.displayName, brand: b })),
];
export function AdapterTabs({
active,
onChange,
}: {
active: string;
onChange: (tab: string) => void;
}) {
return (
<div className="flex border-b border-border">
{TABS.map((tab) => {
const isActive = active === tab.id;
return (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm transition-colors ${
isActive
? 'font-semibold text-accent border-b-2 border-accent'
: 'font-medium text-text-dim hover:text-text'
}`}
>
{tab.brand && (
<span
className="inline-block w-2 h-2 rounded-sm shrink-0"
style={{ backgroundColor: tab.brand.color }}
/>
)}
<span className="font-mono tracking-wide">{tab.label}</span>
</button>
);
})}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { Send } from 'lucide-react';
export function AskQuestion({ toolUseId, input, onRespond }: {
toolUseId: string; input: any; onRespond: (toolUseId: string, response: string) => void;
}) {
const [customText, setCustomText] = useState('');
const [showCustom, setShowCustom] = useState(false);
const [answered, setAnswered] = useState(false);
// SDK AskUserQuestion uses questions[0].question/options structure
const firstQ = input?.questions?.[0];
const question = firstQ?.question || input?.question || input?.text || 'Choose an option';
const options: Array<{ value: string; label: string; description?: string }> = firstQ?.options || input?.options || input?.choices || [];
function select(value: string) { if (answered) return; setAnswered(true); onRespond(toolUseId, value); }
function submitCustom() { if (!customText.trim() || answered) return; setAnswered(true); onRespond(toolUseId, customText.trim()); }
if (answered) return (
<div className="mb-3">
<p className="text-text-dim text-sm italic">Question answered</p>
</div>
);
return (
<div className="mb-3">
<p className="text-sm font-medium font-mono text-text mb-3">{question}</p>
<div className="space-y-2">
{options.map((opt, i) => (
<Button
key={i}
variant="outline"
onClick={() => select(opt.value || opt.label)}
className="w-full justify-start text-left h-auto py-3 px-4"
>
<div>
<div className="text-sm font-medium">{opt.label || opt.value}</div>
{opt.description && <div className="text-text-dim text-xs mt-0.5">{opt.description}</div>}
</div>
</Button>
))}
{!showCustom ? (
<Button variant="ghost" onClick={() => setShowCustom(true)} className="w-full text-sm">
Other...
</Button>
) : (
<div className="flex gap-2">
<input
value={customText}
onChange={(e) => setCustomText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submitCustom()}
placeholder="Type your answer..."
className="flex-1 bg-bg border border-border rounded-md px-3 py-2 text-text text-sm focus:outline-none focus:border-accent"
autoFocus
/>
<Button variant="ghost" size="icon" onClick={submitCustom} disabled={!customText.trim()}>
<Send className="size-4" />
</Button>
</div>
)}
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
export function BlockMarker({ label, color = '#86efac' }: { label: string; color?: string }) {
return (
<div className="flex items-center gap-2 py-1.5">
<div className="flex-1 h-px" style={{ backgroundColor: `${color}30` }} />
<span
className="text-[10px] px-2.5 py-0.5 rounded whitespace-nowrap"
style={{ color, backgroundColor: `${color}15` }}
>
{label}
</span>
<div className="flex-1 h-px" style={{ backgroundColor: `${color}30` }} />
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface BottomSheetProps {
visible: boolean;
onClose?: () => void;
children: ReactNode;
className?: string;
/** Extra classes for the backdrop overlay */
backdropClassName?: string;
/** z-index class (default: z-50) */
zIndex?: 'z-30' | 'z-40' | 'z-50' | 'z-60';
/** Show drag handle bar at top (default: true when onClose provided) */
showHandle?: boolean;
}
/**
* Shared bottom sheet — backdrop + slide-up panel.
* Replaces the repeated pattern across SendToExistingSheet, PermissionOverlay,
* StatusBar pickers, ReviewActionMenu, etc.
*/
export function BottomSheet({ visible, onClose, children, className, backdropClassName, zIndex = 'z-50', showHandle }: BottomSheetProps) {
if (!visible) return null;
const shouldShowHandle = showHandle ?? !!onClose;
return (
<div className={cn('fixed inset-0 flex items-end justify-center', zIndex)} onClick={onClose}>
<div className={cn('absolute inset-0 bg-black/50', backdropClassName)} />
<div
className={cn(
'relative w-full bg-surface border-t border-border rounded-t-xl safe-bottom animate-[slideUp_0.25s_ease-out]',
className,
)}
onClick={e => e.stopPropagation()}
>
{shouldShowHandle && (
<div className="w-8 h-0.5 rounded-sm bg-border mx-auto mt-2 mb-1 cursor-pointer" onClick={onClose} />
)}
{children}
</div>
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
import React, { useRef, useState, useEffect, useMemo, Fragment } from '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';
export type LiveStatus = { type: 'thinking'; text: string } | { type: 'streaming'; text: string };
export interface ChatBodyProps {
messages: ChatMessage[];
streaming: boolean;
pendingResponse?: boolean;
liveStatus: LiveStatus | null;
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;
onSendBack?: (messageId: string) => void;
className?: string;
/** Optional extra content rendered after specific messages (e.g., review markers) */
renderAfterMessage?: (messageId: string, index: number) => React.ReactNode;
/** Optional extra content rendered before the input (e.g., queued messages) */
renderBeforeInput?: () => React.ReactNode;
/** Optional plan rendering — ChatView supplies PlanMode with respondPlan callbacks */
renderPlanBlock?: (planInput: any, hasUserAfter: boolean, key: string | number) => React.ReactNode;
/** Pre-filled text for the input (e.g., when editing a queued message) */
initialInputText?: string;
/** Content rendered between the scroll area and input (e.g., StatusBar) */
renderAboveInput?: () => React.ReactNode;
/** Custom placeholder text for the input */
inputPlaceholder?: string;
/** Hide the input area and show a read-only notice instead */
hideInput?: boolean;
/** External ref to the scroll container (for auto-hide header etc.) */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
}
export function ChatBody({
messages,
streaming,
pendingResponse = false,
liveStatus,
toolStatuses,
onSend,
onStop,
disabled,
interrupted,
sendTargets,
onSendTo,
onSendBack,
className,
renderAfterMessage,
renderBeforeInput,
renderPlanBlock,
initialInputText,
renderAboveInput,
inputPlaceholder,
hideInput,
scrollContainerRef,
}: ChatBodyProps) {
const internalRef = useRef<HTMLDivElement>(null);
const scrollRef = scrollContainerRef || internalRef;
const [userScrolled, setUserScrolled] = useState(false);
// Auto-scroll to bottom when new messages arrive, unless user scrolled up
useEffect(() => {
if (!userScrolled && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, userScrolled]);
function handleScroll() {
const el = scrollRef.current;
if (!el) return;
const scrolled = el.scrollHeight - el.scrollTop - el.clientHeight >= 100;
setUserScrolled((prev) => prev !== scrolled ? scrolled : prev);
}
const lastUserIdx = useMemo(
() => messages.reduce((acc, m, i) => m.role === 'user' ? i : acc, -1),
[messages],
);
function renderContentBlocks(content: any[], isLastAssistant: boolean, hasPlanResponse: boolean) {
const elements: React.JSX.Element[] = [];
const toolBlocks = content.filter((b: any) => b.type === 'tool_use');
// Build a set of tool IDs that have a tool_result in this message's content
// — these tools have definitely completed and should show 'success' even during streaming
const completedToolIds = content.some((b: any) => b.type === 'tool_result')
? 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),
);
const subagentGroups = new Map<string, any[]>();
const topLevelTools: any[] = [];
for (const tool of regularTools) {
if (tool.parent_tool_use_id) {
const group = subagentGroups.get(tool.parent_tool_use_id) || [];
group.push(tool);
subagentGroups.set(tool.parent_tool_use_id, group);
} else {
topLevelTools.push(tool);
}
}
// Also gather sub-tools from toolStatuses (live streaming path —
// progress entries arrive in later batches after the Agent message was already sent)
for (const [id, status] of toolStatuses) {
if (!status.parentToolUseId) continue;
// Skip if already added from content blocks
const existing = subagentGroups.get(status.parentToolUseId);
if (existing?.some((t: any) => t.id === id)) continue;
const group = existing || [];
group.push({
type: 'tool_use',
id: status.toolUseId,
name: status.toolName,
input: status.input,
parent_tool_use_id: status.parentToolUseId,
});
subagentGroups.set(status.parentToolUseId, group);
}
for (const tool of topLevelTools) {
const status = toolStatuses.get(tool.id);
const subTools = subagentGroups.get(tool.id);
// A tool with a matching tool_result is definitively complete — don't show 'running'
const hasResult = completedToolIds?.has(tool.id) ?? false;
if ((tool.name === 'Agent' || tool.name === 'Task') && subTools) {
elements.push(
<SubagentGroup key={tool.id} agentTool={tool} subTools={subTools} toolStatuses={toolStatuses} />,
);
} else {
const fallbackStatus = hasResult ? 'success'
: isLastAssistant && streaming ? 'running'
: isLastAssistant && interrupted ? 'interrupted'
: 'success';
elements.push(
<ToolCallCard key={tool.id} toolName={tool.name} input={tool.input} status={status?.status || fallbackStatus} result={status?.result} />,
);
}
}
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);
if (node) elements.push(node as React.JSX.Element);
}
}
return elements;
}
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">
{messages.length === 0 && !streaming && (
<div className="text-text-dim text-sm text-center py-20 font-mono">Send a message to start</div>
)}
{messages.map((msg, i) => {
if (msg.role === 'interrupted') {
return (
<div key={i} className="flex justify-start mb-3 pl-1">
<div className="flex items-center gap-1.5 text-xs text-text-dim">
<span className="text-text-dim/40">{'\u238F'}</span>
<span className="italic">Interrupted · What should Claude do instead?</span>
</div>
</div>
);
}
if (msg.role === 'plan') {
const planText = msg.content?.find((b: any) => b.type === 'text')?.text || '';
if (renderPlanBlock) {
return <Fragment key={i}>{renderPlanBlock({ plan: planText }, false, i)}</Fragment>;
}
return null;
}
// An assistant message is "last" if it's at the end, or if the only thing after it is an interrupt marker
const isLastAssistant = msg.role === 'assistant' && (
i === messages.length - 1 ||
(i === messages.length - 2 && messages[messages.length - 1]?.role === 'interrupted')
);
const hasUserAfter = msg.role === 'assistant' && i < lastUserIdx;
const toolElements = msg.role === 'assistant' ? renderContentBlocks(msg.content, isLastAssistant, hasUserAfter) : [];
return (
<Fragment key={msg.id || i}>
<div>
<MessageBubble
role={msg.role as 'user' | 'assistant'}
content={msg.content}
isStreaming={isLastAssistant && streaming}
messageId={msg.id}
showActions={msg.role === 'assistant' && !streaming && (!!onSendBack || (!!sendTargets && sendTargets.length > 0))}
sendTargets={sendTargets}
onSendTo={onSendTo}
onSendBack={onSendBack}
/>
{toolElements}
</div>
{msg.id && renderAfterMessage?.(msg.id, i)}
</Fragment>
);
})}
{renderBeforeInput?.()}
{streaming && pendingResponse && (
<div className="flex justify-start mb-3">
<div className="bg-surface border border-border rounded-xl rounded-bl-sm px-4 py-2.5 max-w-[85%]">
<div className="flex items-center gap-2">
<span className="typing-dot w-1.5 h-1.5 bg-accent rounded-full shrink-0" />
<span className="text-xs text-text-dim italic font-mono">
{liveStatus?.type === 'thinking'
? liveStatus.text
: liveStatus?.type === 'streaming'
? 'Responding...'
: 'Working...'}
</span>
</div>
{liveStatus?.type === 'streaming' && liveStatus.text && (
<p className="text-xs text-text-dim/60 mt-1.5 line-clamp-3 break-words">
{liveStatus.text.substring(0, 200)}
</p>
)}
</div>
</div>
)}
</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} />
) : (
<div className="px-4 py-3 text-center text-text-dim/40 text-xs italic">
Review ended read only
</div>
)}
</div>
</div>
);
}
+612
View File
@@ -0,0 +1,612 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react';
import { useChat } from '../hooks/useChat';
import { PLAN_OPTION } from '../lib/ws-types';
import { PermissionOverlay } from './PermissionOverlay';
import { StatusBar } from './StatusBar';
import { AskQuestion } from './AskQuestion';
import { PlanMode } from './PlanMode';
import { ChatBody } from './ChatBody';
import { FloatingReviewPanel, type ReviewPanelHandle } from './FloatingReviewPanel';
import { ReviewActionMenu } from './ReviewActionMenu';
import { SendToExistingSheet } from './SendToExistingSheet';
import { CollapsedReviewCard } from './CollapsedReviewCard';
import { BlockMarker } from './BlockMarker';
import { BottomSheet } from './BottomSheet';
import { api } from '../lib/api';
import { getBrand } from '../lib/adapter-brands';
import { extractTextFromBlocks } from '../lib/content-utils';
import { patchAdapterPrefs } from '../lib/adapter-prefs';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { ChevronLeft, Copy, Check, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
function PlanViewer({ plan }: { plan: string }) {
const [expanded, setExpanded] = useState(false);
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">
<Badge>PLAN</Badge>
<Button variant="ghost" size="icon" onClick={() => setExpanded(false)}>
<X className="w-5 h-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 prose prose-invert prose-sm max-w-none">
<ReactMarkdown>{plan}</ReactMarkdown>
</div>
</div>
);
}
return (
<div className="mb-3">
<button
onClick={() => setExpanded(true)}
className="flex items-center gap-2 px-3 py-2 bg-surface border border-border rounded-md hover:bg-surface-light transition-colors cursor-pointer w-full"
>
<Badge>PLAN</Badge>
<span className="text-xs text-text-secondary truncate flex-1 text-left">
{plan.split('\n').find((l) => l.trim())?.replace(/^#+\s*/, '').slice(0, 60) || 'View plan'}
</span>
<span className="text-xs text-accent-light">View</span>
</button>
</div>
);
}
function ChatHeader({ sessionId, cwd }: { sessionId?: string; cwd?: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
if (!sessionId) return;
navigator.clipboard.writeText(sessionId);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [sessionId]);
const projectName = cwd ? cwd.split('/').filter(Boolean).pop() : null;
const truncatedId = sessionId && sessionId.length > 16
? sessionId.slice(0, 16) + '...'
: sessionId;
return (
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm text-text font-medium font-mono">
{projectName || (sessionId ? 'Session' : 'New Chat')}
</span>
{sessionId && (
<button
onClick={handleCopy}
className="text-[10px] text-text-dim font-mono truncate hover:text-text-secondary transition-colors flex items-center gap-1 cursor-pointer"
>
{truncatedId}
{copied ? (
<Check className="w-2.5 h-2.5 text-success shrink-0" />
) : (
<Copy className="w-2.5 h-2.5 shrink-0" />
)}
</button>
)}
</div>
</div>
);
}
/** Tracks scroll direction inside a child scroll container to auto-hide/show the header. */
function useAutoHideHeader(scrollRef: RefObject<HTMLDivElement | null>) {
const [hidden, setHidden] = useState(false);
const lastScrollTop = useRef(0);
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;
}
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [scrollRef]);
return hidden;
}
export function ChatView({
sessionId: initialSessionId,
cwd,
initialPrompt,
adapter,
onBack,
}: {
sessionId?: string;
cwd?: string;
initialPrompt?: string;
adapter?: string;
onBack: () => void;
}) {
const chatScrollRef = useRef<HTMLDivElement>(null);
const reviewPanelRef = useRef<ReviewPanelHandle>(null);
const reviewRefetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerHidden = useAutoHideHeader(chatScrollRef);
const {
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
interrupted, sessionStatus, adapterConfig, selectedAdapter, permissionRequest, model, permissionMode,
queuedMessage, clearQueuedMessage,
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
historyReview, setHistoryReview,
sendMessage, respondPermission, respondAsk, respondPlan, abort,
updateModel, updatePermissionMode,
} = useChat(initialSessionId, cwd, adapter, initialPrompt);
const [availableAdapters, setAvailableAdapters] = useState<string[]>([]);
useEffect(() => {
api.adapters()
.then(adapters => setAvailableAdapters(adapters.map(a => a.id)))
.catch(console.error);
}, []);
const [editText, setEditText] = useState('');
const handleEditQueued = useCallback(() => {
if (queuedMessage) {
setEditText(queuedMessage);
clearQueuedMessage();
}
}, [queuedMessage, clearQueuedMessage]);
const sendTargets = useMemo(() => {
return availableAdapters
.filter(a => a !== selectedAdapter)
.map(a => ({ adapter: a, label: getBrand(a).displayName }));
}, [availableAdapters, selectedAdapter]);
const sendTargetMenuEntries = useMemo(() => {
return sendTargets.map(t => ({ id: t.adapter, displayName: t.label }));
}, [sendTargets]);
const [reviewMenuMessageId, setReviewMenuMessageId] = useState<string | null>(null);
const [sendToMessageId, setSendToMessageId] = useState<string | null>(null);
interface ReviewRecord {
id: string;
child_adapter: string;
anchor_message_id: string | null;
review_title: string | null;
ended_at: string | null;
end_anchor_message_id: string | null;
}
const [reviews, setReviews] = useState<ReviewRecord[]>([]);
// Shared cleanup for ending/closing an active review
const closeReview = useCallback(async (reviewId?: string) => {
// Empty reviewId means the pending tab's close button — just cancel it
if (reviewId === '') {
setPendingReview(null);
return;
}
const targetId = reviewId || activeReviews[0]?.reviewId;
if (!targetId) return;
const lastMsg = messages[messages.length - 1];
const endAnchorMessageId = lastMsg?.id || undefined;
try { await api.endReview(targetId, endAnchorMessageId); } catch {}
setActiveReviews(prev => prev.filter(r => r.reviewId !== targetId));
setHistoryReview(null);
setPendingReview(null);
}, [activeReviews, messages]);
// Close history panel only (does not affect active review)
const closeHistoryPanel = useCallback(() => {
setHistoryReview(null);
}, []);
// Fetch review history for this session
useEffect(() => {
if (!sessionId) return;
api.getReviews(sessionId).then(setReviews).catch(() => {});
}, [sessionId]);
// Keep reviews in sync with WS events
const prevActiveReviewsRef = useRef(activeReviews);
useEffect(() => {
const prevIds = new Set(prevActiveReviewsRef.current.map(r => r.reviewId));
const currIds = new Set(activeReviews.map(r => r.reviewId));
// New reviews added — batch into a single setReviews call
const newReviews = activeReviews.filter(r => !prevIds.has(r.reviewId));
if (newReviews.length > 0) {
setReviews(prev => {
const existingIds = new Set(prev.map(r => r.id));
const toAdd = newReviews
.filter(r => !existingIds.has(r.reviewId))
.map(r => ({
id: r.reviewId,
child_adapter: r.childAdapter,
anchor_message_id: r.anchorMessageId ?? null,
review_title: r.reviewTitle ?? null,
ended_at: null,
end_anchor_message_id: null,
}));
return toAdd.length > 0 ? [...prev, ...toAdd] : prev;
});
}
// Reviews removed — debounced re-fetch to get ended_at + end_anchor_message_id
const hasRemoved = [...prevIds].some(id => !currIds.has(id));
if (hasRemoved && sessionId) {
if (reviewRefetchTimer.current) clearTimeout(reviewRefetchTimer.current);
reviewRefetchTimer.current = setTimeout(() => {
api.getReviews(sessionId).then(setReviews).catch(() => {});
}, 500);
}
prevActiveReviewsRef.current = activeReviews;
}, [activeReviews, sessionId]);
const { startMarkersByAnchor, endMarkersByAnchor } = useMemo(() => {
const startMap = new Map<string, ReviewRecord[]>();
const endMap = new Map<string, ReviewRecord[]>();
for (const r of reviews) {
if (r.anchor_message_id) {
const existing = startMap.get(r.anchor_message_id) || [];
existing.push(r);
startMap.set(r.anchor_message_id, existing);
}
if (r.ended_at) {
const endKey = r.end_anchor_message_id || r.anchor_message_id;
if (endKey) {
const existing = endMap.get(endKey) || [];
existing.push(r);
endMap.set(endKey, existing);
}
}
}
return { startMarkersByAnchor: startMap, endMarkersByAnchor: endMap };
}, [reviews]);
const handleSendTo = useCallback((messageId: string, _adapter?: string) => {
if (activeReviews.length > 0) {
setSendToMessageId(messageId);
} else {
setReviewMenuMessageId(messageId);
}
}, [activeReviews]);
const handleSendToExisting = useCallback((reviewId: string) => {
if (!sendToMessageId) return;
const msg = messages.find(m => m.id === sendToMessageId);
if (!msg) return;
const text = extractTextFromBlocks(msg.content);
reviewPanelRef.current?.sendToReview(reviewId, text);
setSendToMessageId(null);
setActiveReviewPanel('expanded');
}, [sendToMessageId, messages]);
const handleStartNewFromSheet = useCallback(() => {
if (sendToMessageId) {
setReviewMenuMessageId(sendToMessageId);
setSendToMessageId(null);
}
}, [sendToMessageId]);
const [saveToast, setSaveToast] = useState<{ instruction: string; label: string } | null>(null);
// Pending review: waiting for child session to be created (not yet in activeReviews)
const [pendingReview, setPendingReview] = useState<{
childAdapter: string;
anchorMessageId: string;
reviewTitle: string;
prompt: string;
} | null>(null);
const openReview = useCallback((adapter: string, model: string, prompt: string, title: string) => {
const anchorId = reviewMenuMessageId;
setReviewMenuMessageId(null);
if (!anchorId) return;
patchAdapterPrefs(adapter, { model });
setHistoryReview(null);
setPendingReview({ childAdapter: adapter, anchorMessageId: anchorId, reviewTitle: title, prompt });
setActiveReviewPanel('expanded');
}, [reviewMenuMessageId, cwd]);
const handleDirectSend = useCallback((adapter: string, model: string) => {
const anchorMsg = messages.find(m => m.id === reviewMenuMessageId);
const rawText = anchorMsg ? extractTextFromBlocks(anchorMsg.content) : '';
openReview(adapter, model, rawText, 'direct');
}, [reviewMenuMessageId, messages, openReview]);
const handleSendWithInstruction = useCallback((adapter: string, model: string, instruction: string, isCustom: boolean) => {
const anchorMsg = messages.find(m => m.id === reviewMenuMessageId);
const rawText = anchorMsg ? extractTextFromBlocks(anchorMsg.content) : '';
openReview(adapter, model, `${instruction}\n\n${rawText}`, instruction.substring(0, 30));
if (isCustom) {
setSaveToast({ instruction, label: instruction.substring(0, 30) });
setTimeout(() => setSaveToast(null), 3000);
}
}, [reviewMenuMessageId, messages, openReview]);
const handleOpenReadOnlyReview = useCallback((review: ReviewRecord) => {
setHistoryReview(review);
if (activeReviews.length > 0) setActiveReviewPanel('minimized');
}, [activeReviews]);
const renderReviewMarkers = useCallback((messageId: string, _index: number): React.ReactNode => {
const startReviews = startMarkersByAnchor.get(messageId);
const endReviews = endMarkersByAnchor.get(messageId);
if (!startReviews && !endReviews) return null;
return (
<>
{startReviews?.map((review) => (
<Fragment key={`start-${review.id}`}>
<BlockMarker
label={`${getBrand(review.child_adapter).displayName} ${review.review_title || 'Review'} started`}
color={getBrand(review.child_adapter).color}
/>
{review.ended_at ? (
<CollapsedReviewCard
adapter={review.child_adapter}
title={review.review_title ?? undefined}
summary="Tap to view review conversation"
onClick={() => handleOpenReadOnlyReview(review)}
/>
) : (
<BlockMarker
label={`${getBrand(review.child_adapter).displayName} Review in progress...`}
color={getBrand(review.child_adapter).color}
/>
)}
</Fragment>
))}
{endReviews?.map((review) => (
<BlockMarker
key={`end-${review.id}`}
label="Review ended"
color={getBrand(review.child_adapter).color}
/>
))}
</>
);
}, [startMarkersByAnchor, endMarkersByAnchor, handleOpenReadOnlyReview]);
const renderPlanBlock = useCallback((planInput: any, hasUserAfter: boolean, key: string | number): React.ReactNode => {
if (!hasUserAfter) {
return (
<PlanMode
key={key}
input={planInput.input ?? planInput}
onApprove={() => respondPlan(PLAN_OPTION.MANUALLY_APPROVE)}
onApproveYolo={() => respondPlan(PLAN_OPTION.BYPASS)}
onReject={(feedback: string) => respondPlan(PLAN_OPTION.TEXT_FEEDBACK, feedback || 'Rejected')}
onSendFeedback={(feedback: string) => respondPlan(PLAN_OPTION.TEXT_FEEDBACK, feedback)}
/>
);
}
const planText = planInput.input?.plan || planInput.plan || '';
return <PlanViewer key={key} plan={planText} />;
}, [respondPlan]);
const renderBeforeInput = useCallback((): React.ReactNode => {
if (!queuedMessage) return null;
return (
<div className="flex justify-end mb-3">
<div className="bg-user-bubble text-user-bubble-text/70 rounded-xl rounded-br-md px-3 py-2 max-w-[85%] text-sm">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] bg-white/10 px-1.5 py-0.5 rounded">Queued</span>
</div>
<div className="italic">{queuedMessage}</div>
<div className="flex gap-2 mt-2">
<button onClick={handleEditQueued} className="text-xs text-white/60 hover:text-white">Edit</button>
<button onClick={clearQueuedMessage} className="text-xs text-white/60 hover:text-white">Cancel</button>
</div>
</div>
</div>
);
}, [queuedMessage, handleEditQueued, clearQueuedMessage]);
const renderAboveInput = useCallback((): React.ReactNode => (
<>
{activeReviews.length > 0 && (activeReviewPanel === 'minimized' || historyReview) && (
<div
className="flex items-center gap-1.5 px-4 py-2 border-t border-border cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => { setHistoryReview(null); setActiveReviewPanel('expanded'); }}
>
{activeReviews.map(r => (
<span key={r.reviewId} className="w-1.5 h-1.5 rounded-full" style={{ background: getBrand(r.childAdapter).color }} />
))}
<span className="text-xs text-text-dim flex-1 ml-1 font-mono">
{activeReviews.length} review{activeReviews.length > 1 ? 's' : ''}: {activeReviews.map(r => getBrand(r.childAdapter).displayName).join(' \u00B7 ')}
</span>
<span className="text-xs text-text-dim/50">{'\u25B2'} Expand</span>
</div>
)}
<StatusBar
model={model}
permissionMode={permissionMode}
sessionStatus={sessionStatus}
adapterConfig={adapterConfig}
selectedAdapter={selectedAdapter}
streaming={streaming}
onModelChange={updateModel}
onPermissionModeChange={updatePermissionMode}
/>
</>
), [activeReviews, activeReviewPanel, historyReview, model, permissionMode, sessionStatus, adapterConfig, selectedAdapter, streaming, updateModel, updatePermissionMode]);
const isHistoryPanel = !!historyReview;
// Use ref so onSessionCreatedCallback always reads the latest pendingReview
// (prevents stale closure if a second review is opened while the first is still pending)
const pendingReviewRef = useRef(pendingReview);
pendingReviewRef.current = pendingReview;
const onSessionCreatedCallback = useCallback(async (childSid: string) => {
const pending = pendingReviewRef.current;
if (!sessionId || !pending) return;
try {
const result = await api.registerReview(
sessionId,
childSid,
pending.childAdapter,
pending.anchorMessageId,
pending.prompt,
pending.reviewTitle,
);
setActiveReviews(prev => {
if (prev.some(r => r.reviewId === result.reviewId)) return prev;
return [...prev, {
reviewId: result.reviewId,
childSessionId: childSid,
childCliSessionId: childSid,
childAdapter: pending.childAdapter,
anchorMessageId: pending.anchorMessageId,
reviewTitle: pending.reviewTitle,
}];
});
} catch (err) {
console.error('Failed to register review:', err);
}
setPendingReview(null);
}, [sessionId]);
return (
<div className="flex flex-col h-dvh bg-bg relative overflow-hidden">
{/* 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'}`}>
<Button variant="ghost" size="icon" onClick={onBack}>
<ChevronLeft className="w-5 h-5" />
</Button>
<ChatHeader sessionId={sessionId || initialSessionId} cwd={cwd} />
{wsStatus === 'reconnecting' && (
<span className="text-warning text-xs ml-auto shrink-0">Reconnecting...</span>
)}
</div>
{/* Chat body — messages, tools, input */}
<ChatBody
messages={messages}
streaming={streaming}
pendingResponse={pendingResponse}
liveStatus={liveStatus}
toolStatuses={toolStatuses}
onSend={sendMessage}
onStop={abort}
disabled={false}
interrupted={interrupted}
sendTargets={sendTargets}
onSendTo={handleSendTo}
renderAfterMessage={renderReviewMarkers}
renderBeforeInput={renderBeforeInput}
renderPlanBlock={renderPlanBlock}
initialInputText={editText}
renderAboveInput={renderAboveInput}
scrollContainerRef={chatScrollRef}
className="flex-1"
/>
{/* Floating review panel — active reviews (tabbed) + pending review */}
{activeReviewPanel === 'expanded' && (activeReviews.length > 0 || pendingReview) && (
<FloatingReviewPanel
ref={reviewPanelRef}
reviews={[
...activeReviews.map(r => ({
reviewId: r.reviewId,
childSessionId: r.childSessionId,
childAdapter: r.childAdapter,
reviewTitle: r.reviewTitle,
})),
// Pending review: no reviewId yet, triggers session creation in ReviewTab
...(pendingReview ? [{
reviewId: '',
childSessionId: '',
childAdapter: pendingReview.childAdapter,
reviewTitle: pendingReview.reviewTitle,
}] : []),
]}
onEnd={(reviewId) => closeReview(reviewId)}
onMinimize={() => setActiveReviewPanel('minimized')}
initialPrompt={pendingReview?.prompt || undefined}
cwd={cwd}
onSessionCreated={onSessionCreatedCallback}
/>
)}
{/* Floating review panel — read-only history view */}
{historyReview && (
<FloatingReviewPanel
reviews={[{
reviewId: historyReview.id || historyReview.reviewId || '',
childSessionId: historyReview.child_cli_session_id || historyReview.childSessionId || '',
childAdapter: historyReview.child_adapter || historyReview.childAdapter,
reviewTitle: historyReview.review_title || historyReview.reviewTitle,
}]}
onEnd={() => closeHistoryPanel()}
onMinimize={() => closeHistoryPanel()}
readOnly
/>
)}
{/* Send-to-existing bottom sheet — shown when active reviews exist */}
<SendToExistingSheet
visible={!!sendToMessageId}
activeReviews={activeReviews}
onSendToExisting={handleSendToExisting}
onStartNew={handleStartNewFromSheet}
onClose={() => setSendToMessageId(null)}
/>
{/* Review action menu — two-step bottom sheet for adapter + action selection */}
<ReviewActionMenu
visible={!!reviewMenuMessageId}
adapters={sendTargetMenuEntries}
onDirectSend={handleDirectSend}
onSendWithInstruction={handleSendWithInstruction}
onClose={() => { setReviewMenuMessageId(null); }}
/>
{/* 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">
<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"
onClick={() => {
api.createInstruction(saveToast.label, saveToast.instruction);
setSaveToast(null);
}}
>Save</button>
</div>
)}
{/* Permission / Ask overlays */}
{permissionRequest && permissionRequest.toolName === 'AskUserQuestion' ? (
<BottomSheet visible zIndex="z-40" backdropClassName="bg-black/60" className="p-6" showHandle={false}>
<AskQuestion
toolUseId={permissionRequest.requestId}
input={permissionRequest.input}
onRespond={(requestId: string, response: string) => respondAsk(requestId, response)}
/>
</BottomSheet>
) : permissionRequest ? (
<PermissionOverlay
request={permissionRequest}
onAllow={() => respondPermission(permissionRequest.requestId, 'allow')}
onAllowAll={() => respondPermission(permissionRequest.requestId, 'allow_session')}
onDeny={(msg?: string) => respondPermission(permissionRequest.requestId, 'deny', msg)}
/>
) : null}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { getBrand } from '@/lib/adapter-brands';
export function CollapsedReviewCard({
adapter,
title,
summary,
onClick,
}: {
adapter: string;
title?: string;
summary: string;
onClick: () => void;
}) {
const brand = getBrand(adapter);
return (
<button
onClick={onClick}
className="w-full text-left rounded-md p-3 transition-colors hover:opacity-80"
style={{ backgroundColor: `${brand.color}08`, border: `1px solid ${brand.color}20` }}
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: brand.color }}>
{brand.displayName} {title || 'Review'}
</span>
<span className="text-[10px] text-text-dim ml-auto">tap to expand</span>
</div>
{summary && (
<div className="text-[11px] text-text-dim mt-1 line-clamp-2">{summary}</div>
)}
</button>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function DiffViewer({ filePath, oldString, newString, onClose }: {
filePath: string; oldString: string; newString: string; onClose: () => void;
}) {
const oldLines = oldString.split('\n');
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 gap-3 overflow-hidden">
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="size-4" />
</Button>
<span className="font-mono text-xs text-text truncate">{filePath}</span>
</div>
<div className="flex gap-2 text-xs shrink-0">
<span className="text-danger">-{oldLines.length}</span>
<span className="text-success">+{newLines.length}</span>
</div>
</div>
<div className="flex-1 overflow-auto p-4 font-mono text-xs">
<div className="min-w-0">
{oldLines.map((line, i) => (
<div key={`d-${i}`} className="flex bg-danger/10 whitespace-pre">
<span className="text-text-dim w-8 shrink-0 text-right pr-2 select-none">{i + 1}</span>
<span className="text-danger">- {line}</span>
</div>
))}
<div className="my-2 border-t border-border" />
{newLines.map((line, i) => (
<div key={`a-${i}`} className="flex bg-success/10 whitespace-pre">
<span className="text-text-dim w-8 shrink-0 text-right pr-2 select-none">{i + 1}</span>
<span className="text-success">+ {line}</span>
</div>
))}
</div>
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useState, useEffect, useCallback, Fragment } from 'react';
import { api } from '../lib/api';
import { Button } from './ui/button';
import { X, Folder, ChevronRight } from 'lucide-react';
interface DirEntry {
name: string;
path: string;
hasChildren: boolean;
}
export function DirectoryBrowser({
onSelect,
onClose,
}: {
onSelect: (path: string) => void;
onClose: () => void;
}) {
const [currentPath, setCurrentPath] = useState('');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const browse = useCallback(async (path?: string) => {
setLoading(true);
setError('');
try {
const dirs = await api.browse(path);
setEntries(dirs);
// Derive current path from first entry's parent, or use the explicit path
if (path) {
setCurrentPath(path);
} else if (dirs.length > 0) {
const first = dirs[0].path;
setCurrentPath(first.substring(0, first.lastIndexOf('/')));
}
} catch (err: any) {
setError(err.message || 'Failed to browse directory');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
browse();
}, [browse]);
const breadcrumbs = currentPath
? currentPath.split('/').filter(Boolean)
: [];
const navigateToBreadcrumb = (index: number) => {
const path = '/' + breadcrumbs.slice(0, index + 1).join('/');
browse(path);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-bg border border-border rounded-md w-full max-w-lg max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="text-sm font-semibold text-text">Select Directory</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
{/* Breadcrumbs */}
<div className="px-4 py-2 border-b border-border flex items-center gap-1 text-xs font-mono overflow-x-auto">
<button
onClick={() => browse()}
className="text-accent hover:text-accent-light whitespace-nowrap"
>
~
</button>
{breadcrumbs.map((part, i) => (
<Fragment key={i}>
<span className="text-text-dim">/</span>
<button
onClick={() => navigateToBreadcrumb(i)}
className="text-accent hover:text-accent-light whitespace-nowrap"
>
{part}
</button>
</Fragment>
))}
</div>
{/* Directory list */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{loading ? (
<div className="text-text-dim text-center py-8 text-sm">Loading...</div>
) : error ? (
<div className="text-danger text-center py-8 text-sm">{error}</div>
) : entries.length === 0 ? (
<div className="text-text-dim text-center py-8 text-sm">No subdirectories</div>
) : (
entries.map((entry) => (
<button
key={entry.path}
onClick={() => browse(entry.path)}
className="w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-surface transition-colors"
>
<Folder className="size-4 text-text-dim shrink-0" />
<span className="text-text text-sm truncate flex-1">{entry.name}</span>
{entry.hasChildren && (
<ChevronRight className="size-4 text-text-dim shrink-0" />
)}
</button>
))
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-border flex items-center justify-between gap-3">
<span className="text-xs text-text-dim font-mono truncate flex-1">
{currentPath || '~'}
</span>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
variant="default"
onClick={() => currentPath && onSelect(currentPath)}
disabled={!currentPath}
>
Select
</Button>
</div>
</div>
</div>
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
import React, { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react';
import { useChat } from '../hooks/useChat';
import { ChatBody } from './ChatBody';
import { getBrand } from '@/lib/adapter-brands';
import { extractTextFromBlocks } from '@/lib/content-utils';
import { api } from '@/lib/api';
// ===== Types =====
export interface ReviewEntry {
reviewId: string;
childSessionId: string;
childAdapter: string;
reviewTitle?: string;
}
interface ReviewPanelProps {
reviews: ReviewEntry[];
onEnd: (reviewId: string) => void;
onMinimize: () => void;
initialPrompt?: string;
cwd?: string;
onSessionCreated?: (childSessionId: string) => void;
readOnly?: boolean;
}
export interface ReviewPanelHandle {
sendToReview: (reviewId: string, text: string) => void;
}
// ===== ReviewTab (one per review, keeps useChat hook alive) =====
const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, onSessionCreated, isActive, readOnly, sendRef }: {
review: ReviewEntry;
cwd?: string;
initialPrompt?: string;
onSessionCreated?: (sid: string) => void;
isActive: boolean;
readOnly?: boolean;
sendRef?: React.MutableRefObject<Map<string, (text: string) => void>>;
}) {
const {
messages, streaming, liveStatus, toolStatuses,
sendMessage, abort, sessionId: chatSessionId,
} = useChat(
review.childSessionId || undefined,
cwd,
review.childAdapter,
initialPrompt,
);
// Notify parent when child session is created
const sessionCreatedRef = useRef(false);
useEffect(() => {
if (chatSessionId && !review.childSessionId && onSessionCreated && !sessionCreatedRef.current) {
sessionCreatedRef.current = true;
onSessionCreated(chatSessionId);
}
}, [chatSessionId, review.childSessionId, onSessionCreated]);
// Register sendMessage in parent's ref map for sendToReview
useEffect(() => {
if (sendRef && review.reviewId) {
sendRef.current.set(review.reviewId, sendMessage);
return () => { sendRef.current.delete(review.reviewId); };
}
}, [sendRef, review.reviewId, sendMessage]);
const brand = getBrand(review.childAdapter);
// Send-back handler: extract text from message and send to parent via API
const handleSendBack = useCallback(async (messageId: string) => {
const msg = messages.find(m => m.id === messageId);
if (!msg) return;
const text = extractTextFromBlocks(msg.content);
if (!review.reviewId) {
console.warn('Send back unavailable: review not yet registered');
return;
}
try {
await api.sendBackToParent(review.reviewId, text);
} catch (err: any) {
console.error('Send back failed:', err.message || err);
}
}, [messages, review.reviewId]);
return (
<div style={{ display: isActive ? 'flex' : 'none', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<ChatBody
messages={messages}
streaming={streaming}
liveStatus={liveStatus}
toolStatuses={toolStatuses || new Map()}
onSend={sendMessage}
onStop={abort}
disabled={false}
interrupted={false}
onSendBack={readOnly ? undefined : handleSendBack}
hideInput={readOnly}
inputPlaceholder={`Reply to ${brand.displayName} review...`}
className="flex-1"
/>
</div>
);
});
// ===== Main Panel =====
export const FloatingReviewPanel = forwardRef<ReviewPanelHandle, ReviewPanelProps>(
function FloatingReviewPanel({ reviews, onEnd, onMinimize, initialPrompt, cwd, onSessionCreated, readOnly }, ref) {
const [activeTabIndex, setActiveTabIndex] = useState(Math.max(0, reviews.length - 1));
// Keep activeTabIndex in bounds
useEffect(() => {
if (activeTabIndex >= reviews.length) {
setActiveTabIndex(Math.max(0, reviews.length - 1));
}
}, [reviews.length, activeTabIndex]);
// Auto-focus newest tab when a review is added
const prevCountRef = useRef(reviews.length);
useEffect(() => {
if (reviews.length > prevCountRef.current) {
setActiveTabIndex(reviews.length - 1);
}
prevCountRef.current = reviews.length;
}, [reviews.length]);
// Ref map: reviewId → sendMessage function (populated by each ReviewTab)
const sendRefs = useRef<Map<string, (text: string) => void>>(new Map());
useImperativeHandle(ref, () => ({
sendToReview(reviewId: string, text: string) {
const send = sendRefs.current.get(reviewId);
if (send) send(text);
const idx = reviews.findIndex(r => r.reviewId === reviewId);
if (idx >= 0) setActiveTabIndex(idx);
},
}), [reviews]);
const activeReview = reviews[activeTabIndex] || reviews[0];
if (!activeReview) return null;
const brand = getBrand(activeReview.childAdapter);
return (
<div
className="absolute bottom-0 left-0 right-0 z-10 flex flex-col rounded-t-xl review-panel-compact"
style={{ height: '55%', backgroundColor: '#0f0f11', borderTop: `2px solid ${brand.color}40` }}
>
{/* Handle bar */}
<div className="cursor-pointer mx-auto my-2" onClick={onMinimize}>
<div className="w-8 h-0.5 rounded-sm bg-border" />
</div>
{/* Tab bar (multiple reviews) or single-review header */}
{reviews.length > 1 ? (
<div className="flex items-center border-b border-border">
<div className="flex gap-0.5 px-3 flex-1 overflow-x-auto">
{reviews.map((r, i) => {
const b = getBrand(r.childAdapter);
const tabActive = i === activeTabIndex;
return (
<div
key={r.reviewId || `tab-${i}`}
className="flex items-center gap-0.5 text-xs whitespace-nowrap"
style={{
color: tabActive ? b.color : '#71717a',
borderBottom: tabActive ? `2px solid ${b.color}` : '2px solid transparent',
}}
>
<button
onClick={() => setActiveTabIndex(i)}
className="flex items-center gap-1 px-2 py-2 transition-colors"
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: b.color }} />
{b.displayName}
</button>
{!readOnly && (
<button
className="px-1 py-2 text-[10px] hover:text-red-400 transition-colors"
onClick={() => onEnd(r.reviewId)}
aria-label={`End ${b.displayName} review`}
>{'\u2715'}</button>
)}
</div>
);
})}
</div>
{!readOnly && (
<button
onClick={onMinimize}
className="text-xs text-text-dim/50 hover:text-text-dim px-3 py-2"
>{'\u25BC'}</button>
)}
</div>
) : (
<div className="flex items-center gap-2 px-4 pb-2">
<span
className="text-xs font-semibold px-2 py-0.5 rounded"
style={{
backgroundColor: readOnly ? '#88888820' : `${brand.color}20`,
color: readOnly ? '#888' : brand.color,
}}
>
{brand.displayName}
</span>
<span className="text-xs text-text-dim flex-1 truncate">
{activeReview.reviewTitle || 'Review Session'}
{readOnly && <span className="ml-1 text-text-dim/50">(ended)</span>}
</span>
<button
onClick={onMinimize}
className="text-xs text-text-dim/50 hover:text-text-dim px-1 py-0.5 rounded hover:bg-white/5 transition-colors"
title="Minimize"
>{'\u25BC'}</button>
<button
onClick={() => onEnd(activeReview.reviewId)}
className={readOnly
? 'text-xs text-text-dim/60 hover:text-text-dim px-2 py-1 rounded hover:bg-white/5 transition-colors'
: 'text-xs text-red-400 hover:text-red-300 px-2 py-1 rounded hover:bg-red-400/10 transition-colors'
}
>
{readOnly ? '\u2715' : 'End'}
</button>
</div>
)}
{/* Tabs — ALL rendered to keep hooks alive, only active one visible via CSS */}
{reviews.map((r, i) => (
<ReviewTab
key={r.reviewId || `pending-${i}`}
review={r}
cwd={cwd}
initialPrompt={i === reviews.length - 1 ? initialPrompt : undefined}
onSessionCreated={i === reviews.length - 1 ? onSessionCreated : undefined}
isActive={i === activeTabIndex}
readOnly={readOnly}
sendRef={sendRefs}
/>
))}
</div>
);
}
);
+64
View File
@@ -0,0 +1,64 @@
import { useState, type FormEvent } from 'react';
import { api, setToken } from '../lib/api';
import { Button } from './ui/button';
import { ClawAscii } from './ui/ClawLogo';
export function LoginView({ onLogin }: { onLogin: () => void }) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const { token } = await api.login(password);
setToken(token);
onLogin();
} catch (err: any) {
setError(err.message || 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="flex items-center justify-center min-h-screen bg-bg p-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-4"
>
<div className="text-center space-y-2">
<ClawAscii className="mx-auto text-glow" />
<h1 className="text-2xl font-bold font-mono tracking-wider text-text text-glow">ClawTap</h1>
<p className="text-text-dim font-mono text-xs">remote terminal</p>
</div>
<div>
<span className="text-accent font-mono text-xs mb-1 block">password:</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text font-mono placeholder-text-dim focus:outline-none focus:border-accent focus:shadow-[0_0_8px_var(--color-accent-glow)]"
autoFocus
/>
</div>
{error && (
<p className="text-danger text-sm">{error}</p>
)}
<Button
type="submit"
disabled={loading || !password}
className="w-full"
>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { cn } from '@/lib/utils';
import { splitTextSegments } from '@/lib/text-transforms';
import { extractTextFromBlocks } from '@/lib/content-utils';
import { Copy, Check, Send, CornerDownLeft } from 'lucide-react';
import { CLAUDE_PATTERNS } from './adapters/claude/patterns';
import { InsightBlock } from './adapters/claude/InsightBlock';
// Hoisted to module scope to avoid recreating on every render (ReactMarkdown is expensive)
const markdownComponents = {
code({ className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
const code = String(children).replace(/\n$/, '');
if (match) {
return (
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
customStyle={{ margin: 0, borderRadius: '0.5rem', fontSize: '0.8rem' }}
>
{code}
</SyntaxHighlighter>
);
}
return <code className={className} {...props}>{children}</code>;
},
};
// --- SendDropdown component ---
/** Send-to button — always opens ReviewActionMenu (which handles adapter selection) */
function SendButton({
messageId,
onSendTo,
}: {
messageId: string;
onSendTo: (messageId: string, adapter?: string) => void;
}) {
return (
<button
onClick={() => onSendTo(messageId)}
className="flex items-center justify-center w-6 h-6 text-accent/40 hover:text-accent hover:bg-accent/10 rounded transition-colors"
title="Send to..."
>
<Send className="w-3 h-3" />
</button>
);
}
// --- MessageBubble ---
export function MessageBubble({
role,
content,
isStreaming = false,
messageId,
showActions = false,
sendTargets,
onSendTo,
onSendBack,
}: {
role: 'user' | 'assistant';
content: any[];
isStreaming?: boolean;
messageId?: string;
showActions?: boolean;
sendTargets?: { adapter: string; label: string }[];
onSendTo?: (messageId: string, adapter?: string) => void;
onSendBack?: (messageId: string) => void;
}) {
const [copied, setCopied] = useState(false);
const textContent = content
.filter((b: any) => b.type === 'text')
.map((b: any) => b.text)
.join('');
const segments = role === 'assistant' ? splitTextSegments(textContent, CLAUDE_PATTERNS) : null;
if (!textContent && !isStreaming) return null;
if (role === 'user') {
return (
<div className="flex justify-end mb-3">
<div className="bg-user-bubble text-user-bubble-text rounded-xl rounded-br-sm px-4 py-2 max-w-[85%] break-words text-sm">
{textContent}
</div>
</div>
);
}
return (
<div className="flex justify-start mb-3">
<div className="max-w-[85%]">
<div className="bg-surface border border-border rounded-xl rounded-bl-sm px-4 py-2 break-words overflow-hidden text-sm">
<div className={cn(
'prose prose-invert prose-sm max-w-none font-mono',
'[&_pre]:bg-bg [&_pre]:rounded-md [&_pre]:p-3 [&_pre]:overflow-x-auto',
'[&_code]:text-accent-light',
'[&_a]:text-accent-light',
'[&_p]:my-1.5 [&_ul]:my-1.5 [&_ol]:my-1.5 [&_li]:my-0.5',
'[&_h1]:mt-3 [&_h1]:mb-1.5 [&_h2]:mt-2.5 [&_h2]:mb-1 [&_h3]:mt-2 [&_h3]:mb-1',
)}>
{segments!.map((seg, i) =>
seg.type === 'insight'
? <div key={i} className="not-prose"><InsightBlock text={seg.text} /></div>
: <ReactMarkdown key={i} components={markdownComponents}>{seg.text}</ReactMarkdown>
)}
</div>
{isStreaming && <span className="cursor-blink" />}
</div>
{showActions && !isStreaming && (
<div className="flex gap-1.5 mt-1.5">
<button
onClick={() => { navigator.clipboard.writeText(extractTextFromBlocks(content)); setCopied(true); setTimeout(() => setCopied(false), 2000); }}
className={`flex items-center justify-center w-6 h-6 rounded transition-colors ${
copied ? 'text-accent' : 'text-text-dim/40 hover:text-text-dim hover:bg-white/5'
}`}
title="Copy"
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button>
{messageId && onSendBack && (
<button
onClick={() => onSendBack(messageId)}
className="flex items-center justify-center w-6 h-6 text-accent/40 hover:text-accent hover:bg-accent/10 rounded transition-colors"
title="Send back"
>
<CornerDownLeft className="w-3 h-3" />
</button>
)}
{messageId && !onSendBack && sendTargets && sendTargets.length > 0 && onSendTo && (
<SendButton
messageId={messageId}
onSendTo={onSendTo}
/>
)}
</div>
)}
</div>
</div>
);
}
+217
View File
@@ -0,0 +1,217 @@
import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ShimmerInput } from './ShimmerInput';
import { AdapterIcon } from './AdapterIcon';
import { getBrand, ADAPTER_BRANDS } from '@/lib/adapter-brands';
import { api } from '@/lib/api';
import { MODELS, PERMISSION_MODES, dirName } from '@/lib/utils';
import { loadAdapterPrefs, saveAdapterPrefs } from '@/lib/adapter-prefs';
import { STORAGE } from '@/lib/storage-keys';
type AdapterConfig = {
models: { value: string; label: string; contextWindow: number }[];
permissionModes: { value: string; label: string }[];
effortLevels: { value: string; label: string }[];
effortLabel: string;
};
function SettingCard({ label, value, color, onClick }: { label: string; value: string; color: string; onClick: () => void }) {
return (
<button
onClick={onClick}
className="bg-surface border border-border rounded-md px-3 py-3 flex flex-col items-center gap-1 hover:border-accent/40 transition-colors cursor-pointer"
>
<span className="text-[10px] font-medium font-mono text-text-dim uppercase tracking-wider">{label}</span>
<span className="text-[13px] font-medium font-mono" style={{ color }}>{value}</span>
</button>
);
}
export function NewChatView({
cwd,
onStartChat,
onBack,
}: {
cwd: string;
onStartChat: (options: { adapter: string; model: string; permissionMode: string; effort: string; prompt: string }) => void;
onBack: () => void;
}) {
const [availableAdapters, setAvailableAdapters] = useState<{ id: string; displayName: string; available: boolean }[]>([]);
const [selectedAdapter, setSelectedAdapter] = useState<string>(
() => localStorage.getItem(STORAGE.ADAPTER) || 'claude'
);
const [adapterConfig, setAdapterConfig] = useState<AdapterConfig | null>(null);
const initPrefs = loadAdapterPrefs(selectedAdapter);
const [model, setModel] = useState<string>(initPrefs.model);
const [permissionMode, setPermissionMode] = useState<string>(initPrefs.permissionMode);
const [effort, setEffort] = useState<string>(initPrefs.effort || 'high');
const brand = getBrand(selectedAdapter);
const projectName = dirName(cwd);
// Fetch available adapters on mount
useEffect(() => {
api.adapters().then(setAvailableAdapters).catch(console.error);
}, []);
// Fetch adapter config when adapter changes
useEffect(() => {
api.adapterConfig(selectedAdapter).then((config) => {
setAdapterConfig(config);
const prefs = loadAdapterPrefs(selectedAdapter);
// If current model is not valid for this adapter, pick the first
const validModel = config.models.some((m) => m.value === prefs.model);
if (!validModel && config.models.length > 0) {
const fallback = config.models[0].value;
setModel(fallback);
saveAdapterPrefs(selectedAdapter, { ...prefs, model: fallback });
}
// If current effort is not valid for this adapter, pick the first
if (config.effortLevels.length > 0) {
const validEffort = config.effortLevels.some((e) => e.value === prefs.effort);
if (!validEffort) {
const fallback = config.effortLevels[0].value;
setEffort(fallback);
saveAdapterPrefs(selectedAdapter, { ...prefs, effort: fallback });
}
}
}).catch(console.error);
}, [selectedAdapter]);
// Switch adapter
const switchAdapter = useCallback(() => {
const adapterIds = availableAdapters.filter((a) => a.available).map((a) => a.id);
if (adapterIds.length <= 1) return;
const idx = adapterIds.indexOf(selectedAdapter);
const nextId = adapterIds[(idx + 1) % adapterIds.length];
setSelectedAdapter(nextId);
localStorage.setItem(STORAGE.ADAPTER, nextId);
// Load saved prefs for the new adapter
const prefs = loadAdapterPrefs(nextId);
setModel(prefs.model);
setPermissionMode(prefs.permissionMode);
setEffort(prefs.effort || 'high');
}, [availableAdapters, selectedAdapter]);
// Settings: cycle model
const models = adapterConfig?.models ?? MODELS;
const permissionModes = adapterConfig?.permissionModes ?? PERMISSION_MODES;
const cycleModel = useCallback(() => {
const idx = models.findIndex((m) => m.value === model);
const next = models[(idx + 1) % models.length];
setModel(next.value);
saveAdapterPrefs(selectedAdapter, { model: next.value, permissionMode, effort });
}, [models, model, selectedAdapter, permissionMode, effort]);
const cyclePermission = useCallback(() => {
const idx = permissionModes.findIndex((m) => m.value === permissionMode);
const next = permissionModes[(idx + 1) % permissionModes.length];
setPermissionMode(next.value);
saveAdapterPrefs(selectedAdapter, { model, permissionMode: next.value, effort });
}, [permissionModes, permissionMode, selectedAdapter, model, effort]);
const effortLevels = adapterConfig?.effortLevels ?? [];
const effortLabel = adapterConfig?.effortLabel ?? 'Effort';
const cycleEffort = useCallback(() => {
if (effortLevels.length === 0) return;
const idx = effortLevels.findIndex((e) => e.value === effort);
const next = effortLevels[(idx + 1) % effortLevels.length];
setEffort(next.value);
saveAdapterPrefs(selectedAdapter, { model, permissionMode, effort: next.value });
}, [effortLevels, effort, selectedAdapter, model, permissionMode]);
const effortValue = effortLevels.find((e) => e.value === effort)?.label || effort;
const modelLabel = models.find((m) => m.value === model)?.label || model;
const modeLabel = permissionModes.find((m) => m.value === permissionMode)?.label || permissionMode;
// Other adapters to switch to
const otherAdapters = availableAdapters.filter((a) => a.available && a.id !== selectedAdapter);
const handleSend = useCallback((prompt: string) => {
if (!prompt.trim()) return;
// Save current prefs
saveAdapterPrefs(selectedAdapter, { model, permissionMode, effort });
localStorage.setItem(STORAGE.ADAPTER, selectedAdapter);
onStartChat({ adapter: selectedAdapter, model, permissionMode, effort, prompt });
}, [selectedAdapter, model, permissionMode, effort, onStartChat]);
return (
<div className="flex flex-col h-screen bg-bg">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
<Button variant="ghost" size="icon" onClick={onBack}>
<ChevronLeft className="w-5 h-5" />
</Button>
<span className="text-sm font-medium text-text truncate">{projectName}</span>
</div>
{/* Body — centered content */}
<div className="flex-1 overflow-y-auto flex flex-col items-center justify-center px-4">
{/* Hero Icon */}
<div
className="w-20 h-20 rounded-xl flex items-center justify-center mb-4"
style={{
background: brand.gradient,
boxShadow: `0 8px 32px ${brand.glow}, 0 0 0 1px ${brand.glow}`,
}}
>
<AdapterIcon adapterId={selectedAdapter} size={44} className="!text-white" />
</div>
{/* Adapter name + provider */}
<h1 className="text-xl font-bold font-mono tracking-wide text-text">{brand.displayName}</h1>
<p className="text-sm font-mono text-text-dim mt-0.5">by {brand.provider}</p>
{/* Switch adapter */}
{otherAdapters.length > 0 && (
<div className="mt-3 flex items-center gap-1.5 text-sm text-text-secondary">
<span>Switch to</span>
{otherAdapters.map((a) => {
const b = getBrand(a.id);
return (
<button
key={a.id}
onClick={() => {
setSelectedAdapter(a.id);
localStorage.setItem(STORAGE.ADAPTER, a.id);
const prefs = loadAdapterPrefs(a.id);
setModel(prefs.model);
setPermissionMode(prefs.permissionMode);
setEffort(prefs.effort || 'high');
}}
className="text-xs font-semibold px-1.5 py-0.5 rounded hover:opacity-80 transition-opacity cursor-pointer"
style={{ color: b.color, backgroundColor: b.colorBg }}
>
{b.displayName}
</button>
);
})}
</div>
)}
{/* Settings cards */}
<div className={`grid ${effortLevels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'} gap-3 mt-8 w-full max-w-sm`}>
<SettingCard label="Model" value={modelLabel} color={brand.color} onClick={cycleModel} />
{effortLevels.length > 0 && (
<SettingCard label={effortLabel} value={effortValue} color={brand.color} onClick={cycleEffort} />
)}
<SettingCard label="Permission" value={modeLabel} color={brand.color} onClick={cyclePermission} />
</div>
</div>
{/* Input area */}
<div className="shrink-0 px-4 py-2 safe-bottom">
<ShimmerInput
onSend={handleSend}
disabled={false}
streaming={false}
interrupted={false}
/>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { LoadingAnimation } from './ui/LoadingAnimation';
import { Button } from './ui/button';
import { useState } from 'react';
interface Props {
onRetry: () => void;
}
export function OfflineView({ onRetry }: Props) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard?.writeText('clawtap').then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="min-h-screen bg-bg flex flex-col items-center justify-center px-6 gap-8">
<LoadingAnimation size="lg" label="Connecting..." />
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold text-text font-mono tracking-wider text-glow">ClawTap</h1>
<p className="text-text-dim font-mono">Server not reachable</p>
</div>
<button
onClick={handleCopy}
className="w-full max-w-xs bg-surface rounded-md px-4 py-3 font-mono text-sm text-text hover:bg-surface/80 transition-colors text-left"
>
<span className="text-text-dim">$ </span>
<span>clawtap</span>
{copied && <span className="text-accent text-xs ml-2">Copied!</span>}
</button>
<p className="text-sm text-text-dim text-center max-w-xs">
Run this command on your computer to start the ClawTap server.
</p>
<Button variant="ghost" onClick={onRetry}>
Retry
</Button>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect, useRef } from 'react';
import type { PermissionRequest } from '../hooks/useChat';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { BottomSheet } from './BottomSheet';
function formatInput(toolName: string, input: any): string {
if (toolName === 'Bash' && input?.command) return input.command;
if (input?.file_path) return input.file_path;
if (input?.pattern) return input.pattern;
if (input?.path) return input.path;
if (input?.command) return input.command;
return JSON.stringify(input, null, 2).slice(0, 300);
}
export function PermissionOverlay({ request, onAllow, onAllowAll, onDeny }: {
request: PermissionRequest; onAllow: () => void; onAllowAll: () => void; onDeny: (message?: string) => void;
}) {
const [countdown, setCountdown] = useState(120);
const onDenyRef = useRef(onDeny);
onDenyRef.current = onDeny;
useEffect(() => {
setCountdown(120);
const timer = setInterval(() => {
setCountdown((c) => {
if (c <= 1) { onDenyRef.current('Permission timed out'); return 0; }
return c - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [request.requestId]);
return (
<BottomSheet
visible
onClose={() => onDeny('Dismissed')}
zIndex="z-40"
backdropClassName="backdrop-blur-sm"
className="p-5"
showHandle={false}
>
<div className="flex items-center justify-between mb-4">
<Badge variant="mono">{request.toolName}</Badge>
<span className="text-xs text-text-dim font-mono">{countdown}s</span>
</div>
{request.decisionReason && (
<p className="text-text-dim text-sm mb-3">{request.decisionReason}</p>
)}
<div className="bg-bg rounded-md p-3 mb-4 max-h-40 overflow-y-auto">
<pre className="font-mono text-xs text-text whitespace-pre-wrap break-all">
{formatInput(request.toolName, request.input)}
</pre>
</div>
<div className="flex flex-col gap-2">
<Button variant="default" onClick={onAllow} className="w-full">
Allow
</Button>
<Button variant="secondary" onClick={onAllowAll} className="w-full">
Allow all for this session
</Button>
<Button variant="ghost" onClick={() => onDeny()} className="w-full">
Deny
</Button>
</div>
</BottomSheet>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { X, Send } from 'lucide-react';
export function PlanMode({ input, onApprove, onApproveYolo, onReject, onSendFeedback }: {
input: any; onApprove: () => void; onApproveYolo?: () => void; onReject: (feedback: string) => void; onSendFeedback?: (feedback: string) => void;
}) {
const [showFull, setShowFull] = useState(false);
const [feedback, setFeedback] = useState('');
const planText = input?.plan || input?.content || input?.text || '';
const truncated = planText.length > 500 ? planText.slice(0, 500) + '...' : planText;
const feedbackInput = (
<div className="flex gap-2 items-center">
<input
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Feedback..."
className="flex-1 bg-bg border border-border rounded-md px-3 py-2 text-text text-sm focus:outline-none focus:border-accent"
/>
{onSendFeedback && (
<Button
variant="ghost"
size="icon"
disabled={!feedback.trim()}
onClick={() => { onSendFeedback(feedback); setFeedback(''); }}
className="shrink-0"
>
<Send className="size-4" />
</Button>
)}
</div>
);
const controls = (
<div className="flex flex-col gap-2 mt-3">
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onReject(feedback || 'Rejected')} className="flex-1 text-danger">
Reject
</Button>
<Button variant="default" onClick={onApprove} className="flex-1">
Approve
</Button>
</div>
{onApproveYolo && (
<Button variant="outline" onClick={onApproveYolo} className="w-full text-xs">
Approve (YOLO)
</Button>
)}
</div>
);
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">
<Badge className="font-mono">PLAN</Badge>
<Button variant="ghost" size="icon" onClick={() => setShowFull(false)}>
<X className="size-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown>{planText}</ReactMarkdown>
</div>
</div>
<div className="shrink-0 px-4 py-3 border-t border-border safe-bottom">
{feedbackInput}
{controls}
</div>
</div>
);
}
return (
<div className="mb-3">
<div className="border-l-4 border-accent bg-surface rounded-md p-4">
<div className="flex items-center justify-between mb-3">
<Badge className="font-mono">PLAN</Badge>
<button onClick={() => setShowFull(true)} className="text-xs text-accent-light hover:underline">
Expand
</button>
</div>
<div className="prose prose-invert prose-sm max-w-none mb-2 max-h-48 overflow-hidden">
<ReactMarkdown>{truncated}</ReactMarkdown>
</div>
{feedbackInput}
{controls}
</div>
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
import { useState, useEffect, useCallback } from 'react';
import { AdapterIcon } from './AdapterIcon';
import { BottomSheet } from './BottomSheet';
import { getBrand } from '@/lib/adapter-brands';
import { api } from '@/lib/api';
import type { AdapterConfig, SavedInstruction } from '@/types/adapter';
interface ReviewActionMenuProps {
visible: boolean;
adapters: { id: string; displayName: string }[];
onDirectSend: (adapter: string, model: string) => void;
onSendWithInstruction: (adapter: string, model: string, instruction: string, isCustom: boolean) => void;
onClose: () => void;
}
export function ReviewActionMenu({
visible,
adapters,
onDirectSend,
onSendWithInstruction,
onClose,
}: ReviewActionMenuProps) {
const [step, setStep] = useState<'adapter' | 'action'>('adapter');
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [adapterConfig, setAdapterConfig] = useState<AdapterConfig | null>(null);
const [selectedModel, setSelectedModel] = useState<string>('');
const [instructionsExpanded, setInstructionsExpanded] = useState(false);
const [savedInstructions, setSavedInstructions] = useState<SavedInstruction[]>([]);
const [customText, setCustomText] = useState('');
const resetState = useCallback(() => {
setStep('adapter');
setSelectedAdapter(null);
setAdapterConfig(null);
setSelectedModel('');
setInstructionsExpanded(false);
setCustomText('');
}, []);
useEffect(() => {
if (visible) {
resetState();
api.getInstructions().then(setSavedInstructions).catch(() => {});
}
}, [visible, resetState]);
useEffect(() => {
if (!selectedAdapter) return;
let cancelled = false;
api.adapterConfig(selectedAdapter).then((config) => {
if (cancelled) return;
setAdapterConfig(config);
if (config.models.length > 0) {
setSelectedModel(config.models[0].value);
}
}).catch(() => {});
return () => { cancelled = true; };
}, [selectedAdapter]);
const handleAdapterSelect = useCallback((adapterId: string) => {
setSelectedAdapter(adapterId);
setStep('action');
}, []);
const handleBack = useCallback(() => {
resetState();
}, [resetState]);
const handleDirectSend = useCallback(() => {
if (!selectedAdapter) return;
onDirectSend(selectedAdapter, selectedModel);
onClose();
}, [selectedAdapter, selectedModel, onDirectSend, onClose]);
const handleSavedInstruction = useCallback((instruction: string) => {
if (!selectedAdapter) return;
onSendWithInstruction(selectedAdapter, selectedModel, instruction, false);
onClose();
}, [selectedAdapter, selectedModel, onSendWithInstruction, onClose]);
const handleCustomSend = useCallback(() => {
if (!selectedAdapter || !customText.trim()) return;
onSendWithInstruction(selectedAdapter, selectedModel, customText.trim(), true);
onClose();
}, [selectedAdapter, selectedModel, customText, onSendWithInstruction, onClose]);
const adapterBrand = selectedAdapter ? getBrand(selectedAdapter) : null;
return (
<BottomSheet visible={visible} onClose={onClose} zIndex="z-40" backdropClassName="bg-black/40" className="max-h-[70vh] flex flex-col">
{step === 'adapter' ? (
/* Step 1: Adapter Selection */
<div className="px-4 pb-4 overflow-y-auto">
<h2 className="text-sm font-medium text-text-dim mb-3">Send to</h2>
<div className="space-y-1">
{adapters.map((adapter) => (
<button
key={adapter.id}
onClick={() => handleAdapterSelect(adapter.id)}
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-white/5 active:bg-white/10 transition-colors"
style={{ minHeight: 44 }}
>
<AdapterIcon adapterId={adapter.id} size={20} />
<span className="text-sm text-text">{adapter.displayName}</span>
</button>
))}
</div>
</div>
) : (
/* Step 2: Action Selection */
<div className="px-4 pb-4 overflow-y-auto flex flex-col gap-3">
{/* Back header */}
<button
onClick={handleBack}
className="flex items-center gap-1.5 -ml-1 py-1 text-sm"
style={{ minHeight: 44 }}
>
<span className="text-text-dim"></span>
<span style={{ color: adapterBrand?.color }} className="font-medium">
{adapterBrand?.displayName}
</span>
</button>
{/* Model selector */}
{adapterConfig && adapterConfig.models.length > 0 && (
<div>
<label className="text-xs text-text-dim mb-1 block">Model</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full bg-bg border border-border rounded-md px-3 py-2 text-sm text-text outline-none focus:border-accent appearance-none"
>
{adapterConfig.models.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
)}
{/* Direct Send */}
<button
onClick={handleDirectSend}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-white/5 hover:bg-white/10 active:bg-white/15 transition-colors"
style={{ minHeight: 44 }}
>
<span className="text-base"></span>
<span className="text-sm text-text font-medium">Direct Send</span>
</button>
{/* With Instructions toggle */}
<button
onClick={() => setInstructionsExpanded((prev) => !prev)}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-white/5 hover:bg-white/10 active:bg-white/15 transition-colors"
style={{ minHeight: 44 }}
>
<div className="flex items-center gap-3">
<span className="text-base"></span>
<span className="text-sm text-text font-medium">With Instructions</span>
</div>
<span className="text-xs text-text-dim">
{instructionsExpanded ? '▲' : '▼'}
</span>
</button>
{/* Expanded instructions */}
{instructionsExpanded && (
<div className="space-y-2 pl-1">
{/* Saved instructions list */}
{savedInstructions.map((item) => (
<button
key={item.id}
onClick={() => handleSavedInstruction(item.instruction)}
className="w-full text-left px-3 py-2.5 rounded-md hover:bg-white/5 active:bg-white/10 transition-colors"
style={{ minHeight: 44 }}
>
<div className="text-sm text-text">{item.label}</div>
<div className="text-xs text-text-dim truncate">{item.instruction}</div>
</button>
))}
{/* Divider */}
<div className="flex items-center gap-2 px-3 py-1">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-dim"></span>
<div className="flex-1 h-px bg-border" />
</div>
{/* Custom text input */}
<div className="flex gap-2 px-1">
<input
value={customText}
onChange={(e) => setCustomText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCustomSend();
}}
placeholder="Your instruction..."
className="flex-1 bg-bg border border-border rounded-md px-3 py-2 text-sm text-text outline-none focus:border-accent"
autoFocus
/>
<button
onClick={handleCustomSend}
disabled={!customText.trim()}
className="px-3 py-2 rounded-md bg-accent text-white text-sm font-medium disabled:opacity-40 transition-opacity"
style={{ minHeight: 44 }}
>
</button>
</div>
</div>
)}
</div>
)}
</BottomSheet>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { ChevronLeft, X } from 'lucide-react';
import { api } from '@/lib/api';
import type { SavedInstruction } from '@/types/adapter';
export function SavedInstructionsView({ onBack }: { onBack: () => void }) {
const [instructions, setInstructions] = useState<SavedInstruction[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [newLabel, setNewLabel] = useState('');
const [newInstruction, setNewInstruction] = useState('');
useEffect(() => {
api.getInstructions().then(setInstructions).catch(() => {});
}, []);
const handleDelete = async (id: string) => {
if (!window.confirm('Delete this instruction?')) return;
try {
await api.deleteInstruction(id);
setInstructions((prev) => prev.filter((i) => i.id !== id));
} catch {
// ignore
}
};
const handleSave = async () => {
if (!newLabel.trim() || !newInstruction.trim()) return;
try {
const created = await api.createInstruction(newLabel.trim(), newInstruction.trim());
setInstructions((prev) => [
...prev,
{ ...created, created_at: new Date().toISOString() },
]);
setNewLabel('');
setNewInstruction('');
setShowAddForm(false);
} catch {
// ignore
}
};
return (
<div className="flex flex-col h-full bg-bg">
{/* Header */}
<div className="flex items-center px-4 py-3 border-b border-border">
<button
onClick={onBack}
className="text-text-dim hover:text-text mr-2"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="font-medium text-text font-mono tracking-wide">Saved Instructions</span>
<div className="flex-1" />
<button
onClick={() => setShowAddForm(true)}
className="text-accent text-sm font-medium"
>
+ Add
</button>
</div>
{/* Add form */}
{showAddForm && (
<div className="px-4 py-3 border-b border-border space-y-2">
<input
type="text"
placeholder="名稱"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
className="w-full bg-surface border border-border rounded-md px-3 py-2 text-text text-sm outline-none focus:border-accent"
/>
<textarea
placeholder="Instruction 內容..."
value={newInstruction}
onChange={(e) => setNewInstruction(e.target.value)}
rows={4}
className="w-full bg-surface border border-border rounded-md px-3 py-2 text-text text-sm outline-none focus:border-accent resize-none"
/>
<div className="flex gap-2 justify-end">
<button
onClick={() => {
setShowAddForm(false);
setNewLabel('');
setNewInstruction('');
}}
className="px-3 py-1.5 text-sm text-text-dim hover:text-text rounded-md"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-3 py-1.5 text-sm bg-accent hover:bg-accent/80 text-white rounded-md"
>
Save
</button>
</div>
</div>
)}
{/* List */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{instructions.length === 0 && !showAddForm && (
<div className="flex-1 flex items-center justify-center text-text-dim text-sm h-full">
No saved instructions
</div>
)}
{instructions.map((item) => (
<div
key={item.id}
className="bg-surface border border-border rounded-md px-4 py-3 flex items-start gap-3"
>
<div className="flex-1 min-w-0">
<div className="font-medium text-text text-sm">{item.label}</div>
<div className="text-text-dim text-xs mt-1 line-clamp-2">
{item.instruction}
</div>
</div>
<button
onClick={() => handleDelete(item.id)}
className="text-red-400/60 hover:text-red-400 shrink-0 mt-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { getBrand } from '../lib/adapter-brands';
import type { ReviewInfo } from '../hooks/useChat';
import { BottomSheet } from './BottomSheet';
interface SendToExistingSheetProps {
visible: boolean;
activeReviews: ReviewInfo[];
onSendToExisting: (reviewId: string) => void;
onStartNew: () => void;
onClose: () => void;
}
export function SendToExistingSheet({ visible, activeReviews, onSendToExisting, onStartNew, onClose }: SendToExistingSheetProps) {
return (
<BottomSheet visible={visible} onClose={onClose} className="max-w-lg p-4 space-y-2">
<p className="text-xs text-text-dim font-mono mb-2">Send to active review</p>
{activeReviews.map(r => {
const brand = getBrand(r.childAdapter);
return (
<button
key={r.reviewId}
onClick={() => onSendToExisting(r.reviewId)}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-border hover:bg-white/5 transition-colors text-left"
>
<span
className="text-xs font-semibold px-2 py-0.5 rounded"
style={{ backgroundColor: `${brand.color}20`, color: brand.color }}
>
{brand.displayName}
</span>
<span className="text-sm text-text font-mono flex-1 truncate">
{r.reviewTitle || 'Review'}
</span>
<span className="text-xs text-text-dim">{'\u2192'}</span>
</button>
);
})}
<div className="border-t border-border pt-2 mt-2">
<button
onClick={onStartNew}
className="w-full text-left px-3 py-2 text-xs text-text-dim hover:text-text hover:bg-white/5 rounded-lg transition-colors font-mono"
>
Start new review...
</button>
</div>
</BottomSheet>
);
}
+458
View File
@@ -0,0 +1,458 @@
import { useState, useEffect, useRef } from 'react';
import { useSessions } from '../hooks/useSessions';
import { usePushNotifications } from '../hooks/usePushNotifications';
import { DirectoryBrowser } from './DirectoryBrowser';
import { BottomSheet } from './BottomSheet';
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 { timeAgo, dirName, PERMISSION_MODES } from '@/lib/utils';
import { api } from '@/lib/api';
import { getBrand, ADAPTER_BRANDS } from '@/lib/adapter-brands';
export function SessionsView({
onOpenChat,
onLogout,
onOpenSettings,
installPrompt,
onInstall,
onDismissInstall,
}: {
onOpenChat: (sessionId?: string, cwd?: string, adapter?: string) => void;
onLogout: () => void;
onOpenSettings: () => void;
installPrompt?: unknown;
onInstall?: () => void;
onDismissInstall?: () => void;
}) {
const {
sessions,
projects,
selectedProjectDir,
selectProject,
sessionCounts,
loading,
refresh,
activeTab,
setActiveTab,
activeSessions,
activeSessionIds,
refreshActive,
} = useSessions();
const { supported: pushSupported, subscribed, subscribe, unsubscribe } = usePushNotifications();
const [showBrowser, setShowBrowser] = useState(false);
const [showHeaderMenu, setShowHeaderMenu] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [pending, setPending] = useState<Record<string, number>>({});
const [adapterFilter, setAdapterFilter] = useState('all');
const [contextMenu, setContextMenu] = useState<{
sessionId: string;
adapter: string;
isActive: boolean;
} | null>(null);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLongPressStart = (session: any, isActive: boolean) => {
longPressTimer.current = setTimeout(() => {
setContextMenu({
sessionId: session.sessionId,
adapter: session.adapter || 'claude',
isActive,
});
}, 500);
};
const handleLongPressEnd = () => {
if (longPressTimer.current) clearTimeout(longPressTimer.current);
};
// Fetch pending notification counts — initial fetch + debounced real-time via SW postMessage
useEffect(() => {
if (!subscribed) { setPending({}); return; }
api.pushPending().then(setPending).catch(() => {});
let debounceTimer: ReturnType<typeof setTimeout>;
const handler = (event: MessageEvent) => {
if (event.data?.type === 'PUSH_RECEIVED') {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
api.pushPending().then(setPending).catch(() => {});
}, 300);
}
};
navigator.serviceWorker?.addEventListener('message', handler);
return () => {
clearTimeout(debounceTimer);
navigator.serviceWorker?.removeEventListener('message', handler);
};
}, [subscribed]);
const handleBrowseSelect = (path: string) => {
setShowBrowser(false);
onOpenChat(undefined, path);
};
const handleNewChat = () => onOpenChat(undefined, selectedProjectDir || undefined);
const contextMenuSheet = contextMenu && (() => {
const otherAdapterId = Object.keys(ADAPTER_BRANDS).find(id => id !== contextMenu.adapter) || 'claude';
const otherBrand = getBrand(otherAdapterId);
return (
<BottomSheet visible onClose={() => setContextMenu(null)} zIndex="z-40" backdropClassName="bg-black/60" className="p-4 pb-8">
<div className="space-y-2">
{contextMenu.isActive && (
<button
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-white/5 transition-colors"
onClick={() => { /* TODO: cross-adapter hand-off */ setContextMenu(null); }}
>
<ArrowRightLeft className="w-5 h-5 text-text-dim" />
<div className="text-left">
<div className="text-sm font-medium">Hand off to {otherBrand.displayName}</div>
<div className="text-xs text-text-dim">Continue with {otherBrand.displayName}</div>
</div>
</button>
)}
<button
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-white/5 transition-colors"
onClick={() => { /* TODO: use as reference */ setContextMenu(null); }}
>
<ClipboardList className="w-5 h-5 text-text-dim" />
<div className="text-left">
<div className="text-sm font-medium">Use as reference in {otherBrand.displayName}</div>
<div className="text-xs text-text-dim">Start new chat with context</div>
</div>
</button>
</div>
</BottomSheet>
);
})();
// --- Sessions list (drill-down into a project) ---
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 gap-2 min-w-0">
<Button
variant="ghost"
size="icon"
onClick={() => selectProject(null)}
>
<ChevronLeft className="w-5 h-5" />
</Button>
<span className="text-sm font-medium text-text truncate">
{dirName(selectedProjectDir)}
</span>
</div>
<Button onClick={handleNewChat} size="sm">
New Chat
</Button>
</div>
<AdapterTabs active={adapterFilter} onChange={setAdapterFilter} />
<div className="flex-1 overflow-y-auto">
{(() => {
const filteredSessions = adapterFilter === 'all'
? sessions
: sessions.filter((s: any) => s.adapter === adapterFilter);
return loading && sessions.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<LoadingAnimation size="md" label="Loading sessions..." />
</div>
) : filteredSessions.length === 0 ? (
<div className="text-center py-12">
<p className="text-text-dim text-sm">No sessions yet.</p>
{adapterFilter === 'all' && (
<Button variant="link" onClick={handleNewChat} className="mt-2">
Start a new chat
</Button>
)}
</div>
) : (
<div>
{filteredSessions.map((session: any) => {
const brand = getBrand(session.adapter);
const isActive = activeSessionIds.has(session.sessionId);
return (
<button
key={session.sessionId}
onClick={() => onOpenChat(session.sessionId, session.cwd, session.adapter)}
onTouchStart={() => handleLongPressStart(session, isActive)}
onTouchEnd={handleLongPressEnd}
onMouseDown={() => handleLongPressStart(session, isActive)}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
className="w-full text-left px-4 py-3 border-b border-border hover:bg-surface transition-colors"
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm text-text truncate flex-1 mr-3 flex items-center gap-1.5">
{isActive && (
<span className="inline-block w-2 h-2 rounded-full bg-success shrink-0" />
)}
<span
style={{ color: brand.color, backgroundColor: brand.colorBg }}
className="text-[10px] font-semibold px-1.5 rounded shrink-0"
>
{brand.displayName}
</span>
{session.firstPrompt || 'Untitled session'}
</span>
<span className="text-xs text-text-dim whitespace-nowrap shrink-0 font-mono">
{session.lastModified ? timeAgo(session.lastModified) : ''}
</span>
</div>
<div className="text-[10px] font-mono text-text-dim truncate">
{session.sessionId}
</div>
</button>
);
})}
</div>
);
})()}
<div className="px-4 py-3">
<Button variant="ghost" onClick={refresh} className="w-full gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
Refresh
</Button>
</div>
</div>
{contextMenuSheet}
</div>
);
}
// --- Projects list (default view) ---
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">
<span className="flex items-center gap-1.5 text-lg font-medium text-text font-mono tracking-wider">
<svg width="20" height="15" viewBox="0 0 8 6" style={{ imageRendering: 'pixelated' }} className="text-accent">
<rect x="4" y="0" width="4" height="1" fill="currentColor"/>
<rect x="3" y="1" width="2" height="1" fill="currentColor"/>
<rect x="0" y="2" width="4" height="2" fill="currentColor"/>
<rect x="4" y="2" width="1" height="2" fill="currentColor" opacity="0.5"/>
<rect x="3" y="4" width="2" height="1" fill="currentColor"/>
<rect x="4" y="5" width="4" height="1" fill="currentColor"/>
</svg>
ClawTap
</span>
<div className="flex items-center gap-1.5">
<Button onClick={() => setShowBrowser(true)} size="sm">
<Plus className="w-4 h-4 mr-1" />
New Project
</Button>
<div className="relative">
<button
onClick={() => setShowHeaderMenu(prev => !prev)}
className="p-2 rounded-md hover:bg-surface transition-colors"
>
<MoreVertical className="w-4 h-4 text-text-dim" />
</button>
{showHeaderMenu && (
<>
<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"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg>
Settings
</button>
<div className="border-t border-border my-1" />
<button
onClick={() => { onLogout(); setShowHeaderMenu(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-mono text-danger hover:bg-white/5 transition-colors"
>
Logout
</button>
</div>
</>
)}
</div>
</div>
</div>
{!!installPrompt && (
<div className="flex items-center gap-3 px-4 py-2.5 bg-accent/10 border-b border-accent/20">
<span className="text-xs text-text flex-1 font-mono">Install ClawTap for a better experience</span>
<button onClick={onInstall} className="text-xs font-medium text-accent hover:text-accent-light cursor-pointer">Install</button>
<button onClick={onDismissInstall} className="text-xs text-text-dim hover:text-text cursor-pointer">Dismiss</button>
</div>
)}
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab('projects')}
className={`flex-1 py-2.5 text-sm font-medium transition-colors font-mono tracking-wide ${
activeTab === 'projects' ? 'text-accent border-b-2 border-accent' : 'text-text-dim'
}`}
>
Projects
</button>
<button
onClick={() => setActiveTab('active')}
className={`flex-1 py-2.5 text-sm font-medium transition-colors font-mono tracking-wide ${
activeTab === 'active' ? 'text-accent border-b-2 border-accent' : 'text-text-dim'
}`}
>
Active ({activeSessions.length})
</button>
</div>
{activeTab === 'active' ? (
<div className="flex-1 overflow-y-auto">
{activeSessions.length === 0 ? (
<div className="text-text-dim text-sm text-center py-12">No active sessions</div>
) : (
<div>
{activeSessions.map((session: any) => {
const isExpanded = expandedId === session.sessionId;
return (
<div key={session.sessionId} className="border-b border-border">
<button
onClick={() => setExpandedId(isExpanded ? null : session.sessionId)}
onTouchStart={() => handleLongPressStart(session, true)}
onTouchEnd={handleLongPressEnd}
onMouseDown={() => handleLongPressStart(session, true)}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
className="w-full text-left px-4 py-3 hover:bg-surface transition-colors"
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm text-text truncate flex-1 mr-3">
<span className="inline-block w-2 h-2 rounded-full bg-success mr-1.5 shrink-0" />
{session.firstPrompt || session.sessionId}
</span>
<div className="flex items-center gap-2 shrink-0">
{pending[session.sessionId] > 0 && (
<span className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full bg-danger text-white text-xs font-medium">
{pending[session.sessionId]}
</span>
)}
<span className="text-xs text-text-dim whitespace-nowrap font-mono">
{session.lastActivity ? timeAgo(session.lastActivity) : ''}
</span>
</div>
</div>
<div className="text-[10px] text-text-dim truncate flex items-center gap-1.5">
<span>{dirName(session.cwd || '')}</span>
<span>·</span>
<span>{PERMISSION_MODES.find(m => m.value === session.permissionMode)?.label ?? session.permissionMode}</span>
{(session.hasDesktop || session.hasClients) && (
<>
<span>·</span>
<span>{[session.hasDesktop && 'desktop', session.clientCount > 0 && `${session.clientCount} connected`].filter(Boolean).join(' · ')}</span>
</>
)}
</div>
</button>
{isExpanded && (
<div className="px-4 pb-3 flex gap-2">
<Button
size="sm"
onClick={() => onOpenChat(session.sessionId, session.cwd, session.adapter)}
>
Connect
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300"
onClick={async () => {
try {
await api.destroySession(session.sessionId, session.adapter);
setExpandedId(null);
refreshActive();
} catch (e) {
console.error('Failed to disconnect:', e);
}
}}
>
Disconnect
</Button>
</div>
)}
</div>
);
})}
</div>
)}
<div className="px-4 py-3">
<Button variant="ghost" onClick={refreshActive} className="w-full gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
Refresh
</Button>
</div>
</div>
) : (
<div className="flex-1 overflow-y-auto">
{loading && projects.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<LoadingAnimation size="md" label="Loading projects..." />
</div>
) : projects.length === 0 ? (
<div className="text-center py-12">
<p className="text-text-dim text-sm">No projects yet.</p>
<Button variant="link" onClick={() => setShowBrowser(true)} className="mt-2">
Browse for a directory to start
</Button>
</div>
) : (
<div>
{projects.map((project) => (
<button
key={project}
onClick={() => selectProject(project)}
className="w-full text-left px-4 py-3 border-b border-border hover:bg-surface transition-colors flex items-center"
>
<div className="min-w-0 flex-1">
<div className="text-sm text-text truncate font-mono">{dirName(project)}</div>
<div className="text-xs text-text-dim truncate mt-0.5 font-mono">{project}</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-3">
<Badge variant="secondary">
{sessionCounts[project] || 0}
</Badge>
<ChevronRight className="w-4 h-4 text-text-dim" />
</div>
</button>
))}
</div>
)}
<div className="px-4 py-3">
<Button variant="ghost" onClick={refresh} className="w-full gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
Refresh
</Button>
</div>
</div>
)}
{showBrowser && (
<DirectoryBrowser
onSelect={handleBrowseSelect}
onClose={() => setShowBrowser(false)}
/>
)}
{contextMenuSheet}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight, ClipboardList, Bell, Info } from 'lucide-react';
import { api } from '@/lib/api';
import { AdapterIcon } from './AdapterIcon';
import { SavedInstructionsView } from './SavedInstructionsView';
import { AdapterSettingsSection } from './AdapterSettingsSection';
import { usePushNotifications } from '@/hooks/usePushNotifications';
interface Adapter {
id: string;
displayName: string;
available: boolean;
}
export function SettingsView({ onBack }: { onBack: () => void }) {
const [subView, setSubView] = useState<'main' | 'instructions' | string>('main');
const [adapters, setAdapters] = useState<Adapter[]>([]);
const [version, setVersion] = useState<string>('');
const { supported, subscribed, subscribe, unsubscribe } = usePushNotifications();
const [toggling, setToggling] = useState(false);
useEffect(() => {
api.adapters().then(setAdapters).catch(() => {});
fetch('/api/health')
.then(r => r.json())
.then((data: { version: string }) => setVersion(data.version))
.catch(() => {});
}, []);
if (subView === 'instructions') {
return <SavedInstructionsView onBack={() => setSubView('main')} />;
}
const matchedAdapter = adapters.find(a => a.id === subView);
if (matchedAdapter) {
return <AdapterSettingsSection adapter={subView} onBack={() => setSubView('main')} />;
}
const handleNotificationToggle = async () => {
if (toggling) return;
setToggling(true);
try {
if (subscribed) {
await unsubscribe();
} else {
await subscribe();
}
} finally {
setToggling(false);
}
};
const availableAdapters = adapters.filter(a => a.available);
return (
<div className="flex flex-col h-full bg-bg">
{/* Header */}
<div className="flex items-center px-4 py-3 border-b border-border">
<button onClick={onBack} className="text-text-dim hover:text-text mr-2"><ChevronLeft className="w-5 h-5" /></button>
<span className="text-lg font-medium text-text font-mono tracking-wide">Settings</span>
</div>
{/* Main list */}
<div className="flex-1 overflow-y-auto">
{/* Saved Instructions */}
<button
onClick={() => setSubView('instructions')}
className="w-full flex items-center px-4 min-h-[48px] border-b border-border hover:bg-surface-hover transition-colors"
>
<span className="mr-3"><ClipboardList className="w-5 h-5 text-text-dim" /></span>
<span className="flex-1 text-left text-text text-sm">Saved Instructions</span>
<ChevronRight className="w-4 h-4 text-text-dim" />
</button>
{/* Per-adapter rows */}
{availableAdapters.map(adapter => (
<button
key={adapter.id}
onClick={() => setSubView(adapter.id)}
className="w-full flex items-center px-4 min-h-[48px] border-b border-border hover:bg-surface-hover transition-colors"
>
<span className="mr-3">
<AdapterIcon adapterId={adapter.id} size={20} />
</span>
<span className="flex-1 text-left text-text text-sm">{adapter.displayName}</span>
<ChevronRight className="w-4 h-4 text-text-dim" />
</button>
))}
{/* Notifications */}
{supported && (
<div className="w-full flex items-center px-4 min-h-[48px] border-b border-border">
<span className="mr-3"><Bell className="w-5 h-5 text-text-dim" /></span>
<span className="flex-1 text-text text-sm">Notifications</span>
<button
onClick={handleNotificationToggle}
disabled={toggling}
className={`relative w-11 h-6 rounded-full transition-colors ${
subscribed ? 'bg-accent' : 'bg-white/20'
} ${toggling ? 'opacity-50' : ''}`}
aria-label="Toggle notifications"
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white transition-transform ${
subscribed ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
)}
{/* About */}
<div className="w-full flex items-center px-4 min-h-[48px] border-b border-border">
<span className="mr-3"><Info className="w-5 h-5 text-text-dim" /></span>
<span className="flex-1 text-text text-sm">About</span>
<span className="text-text-dim text-xs font-mono">
{version ? `ClawTap v${version}` : 'ClawTap'}
</span>
</div>
</div>
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
import { Send, Square, ImagePlus, X, Mic } from 'lucide-react';
import { useVoiceInput } from '../hooks/useVoiceInput';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { STORAGE } from '@/lib/storage-keys';
const SHIMMER_KEYWORDS = ['ultrathink', 'megathink', 'think harder'];
function hasShimmerKeyword(text: string): boolean {
const lower = text.toLowerCase();
return SHIMMER_KEYWORDS.some((kw) => lower.includes(kw));
}
/** Split text into segments, marking which ones are shimmer keywords */
function splitByKeywords(text: string): { text: string; shimmer: boolean }[] {
const lower = text.toLowerCase();
const segments: { text: string; shimmer: boolean }[] = [];
let remaining = text;
let pos = 0;
while (pos < text.length) {
let earliest = -1;
let matchedKw = '';
for (const kw of SHIMMER_KEYWORDS) {
const idx = lower.indexOf(kw, pos);
if (idx !== -1 && (earliest === -1 || idx < earliest)) {
earliest = idx;
matchedKw = kw;
}
}
if (earliest === -1) {
segments.push({ text: text.slice(pos), shimmer: false });
break;
}
if (earliest > pos) {
segments.push({ text: text.slice(pos, earliest), shimmer: false });
}
segments.push({ text: text.slice(earliest, earliest + matchedKw.length), shimmer: true });
pos = earliest + matchedKw.length;
}
return segments;
}
export function ShimmerInput({ onSend, onStop, disabled, streaming, interrupted, initialText = '', placeholder: placeholderProp }: {
onSend: (text: string) => void;
onStop?: () => void;
disabled: boolean;
streaming?: boolean;
interrupted?: boolean;
initialText?: string;
placeholder?: string;
}) {
const [text, setText] = useState('');
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isRecording, interimText, toggleRecording, supported: voiceSupported } = useVoiceInput(
(transcript) => {
setText((prev) => prev + transcript);
}
);
// Initialize text: initialText takes priority over saved draft
useEffect(() => {
if (initialText) {
setText(initialText);
textareaRef.current?.focus();
} else {
const saved = localStorage.getItem(STORAGE.DRAFT);
if (saved) setText(saved);
}
}, [initialText]);
// Debounce-save draft on text change
const draftTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
clearTimeout(draftTimer.current);
if (text.trim()) {
draftTimer.current = setTimeout(() => {
localStorage.setItem(STORAGE.DRAFT, text);
}, 500);
} else {
localStorage.removeItem(STORAGE.DRAFT);
}
return () => clearTimeout(draftTimer.current);
}, [text]);
// Sync textarea value → React state for programmatic changes
// (browser autofill, Playwright fill, accessibility tools, etc.)
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
const sync = () => {
const v = el.value;
setText(prev => prev !== v ? v : prev);
};
el.addEventListener('input', sync);
return () => el.removeEventListener('input', sync);
}, []);
const isShimmer = hasShimmerKeyword(text);
async function handleSend() {
if (disabled || uploading) return;
if (!text.trim() && !imageFile) return;
let message = text.trim();
// Upload image first if attached
if (imageFile) {
setUploading(true);
try {
const { path } = await api.uploadImage(imageFile);
// Prepend image path to message so Claude can read it
message = message
? `[Image: ${path}]\n\n${message}`
: `Please analyze this image: ${path}`;
} catch (err) {
console.error('Image upload failed:', err);
setUploading(false);
return;
}
setUploading(false);
}
onSend(message);
localStorage.removeItem(STORAGE.DRAFT);
setText('');
setImageFile(null);
setImagePreview(null);
if (textareaRef.current) textareaRef.current.style.height = 'auto';
}
function handleInput() {
const el = textareaRef.current;
if (el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px'; }
}
function attachImage(file: File) {
setImageFile(file);
const reader = new FileReader();
reader.onload = () => setImagePreview(reader.result as string);
reader.readAsDataURL(file);
}
function handleImageSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
attachImage(file);
e.target.value = '';
}
function removeImage() {
setImageFile(null);
setImagePreview(null);
}
async function handlePaste(e: React.ClipboardEvent) {
const items = e.clipboardData?.items;
if (items) {
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) attachImage(file);
return;
}
}
// items exist but no images — normal text paste, let it through
return;
}
// Fallback: no clipboardData.items (some mobile browsers)
try {
const clipItems = await navigator.clipboard.read();
for (const clipItem of clipItems) {
const imageType = clipItem.types.find(t => t.startsWith('image/'));
if (imageType) {
const blob = await clipItem.getType(imageType);
const file = new File([blob], 'pasted-image.png', { type: imageType });
attachImage(file);
return;
}
}
} catch {}
}
return (
<div onPaste={handlePaste}>
{/* Image preview */}
{imagePreview && (
<div className="flex items-center gap-2 mb-2 px-1">
<div className="relative">
<img src={imagePreview} alt="Upload" className="h-16 rounded-md border border-border object-cover" />
<button
onClick={removeImage}
className="absolute -top-1.5 -right-1.5 bg-bg border border-border rounded-md p-0.5 hover:bg-surface cursor-pointer"
>
<X className="size-3" />
</button>
</div>
<span className="text-xs text-text-dim">{imageFile?.name}</span>
</div>
)}
<div className="flex items-center gap-1.5">
{/* Image button */}
<Button
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
className="shrink-0 h-10 w-10"
>
<ImagePlus className="size-5" />
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
{voiceSupported && (
<button
type="button"
onClick={toggleRecording}
className={`p-2 rounded-md transition-colors ${
isRecording
? 'text-red-500 bg-red-500/10 animate-pulse'
: 'text-gray-400 hover:text-gray-300 hover:bg-white/5'
}`}
title={isRecording ? 'Stop recording' : 'Voice input'}
>
<Mic size={20} />
</button>
)}
<div className="relative flex-1">
{/* Overlay: renders keyword-only shimmer on top of transparent textarea text */}
{isShimmer && (
<div
aria-hidden
className="absolute inset-0 px-4 py-2.5 text-sm pointer-events-none whitespace-pre-wrap break-words overflow-hidden"
style={{ maxHeight: 120 }}
>
{splitByKeywords(text).map((seg, i) =>
seg.shimmer
? <span key={i} className="shimmer-text text-transparent">{seg.text}</span>
: <span key={i} className="text-text">{seg.text}</span>
)}
</div>
)}
<textarea
ref={textareaRef}
value={text}
onChange={(e) => { setText(e.target.value); handleInput(); }}
placeholder={isRecording && interimText ? interimText : imageFile ? "Add a message (optional)..." : interrupted ? "What should Claude do instead?" : placeholderProp || "Send a message..."}
rows={1}
className={cn(
'w-full bg-surface border border-border rounded-md px-4 py-2.5 text-sm text-text placeholder-text-dim font-mono resize-none',
'focus:outline-none focus:border-accent focus:shadow-[0_0_6px_var(--color-accent-glow)] transition-colors',
isShimmer && '!text-transparent caret-text',
)}
style={{ maxHeight: 120 }}
/>
</div>
{streaming && onStop && (
<Button
variant="ghost"
size="icon"
onClick={onStop}
className="shrink-0 h-10 w-10 text-danger"
>
<Square className="size-4 fill-current" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={handleSend}
disabled={disabled || uploading || (!text.trim() && !imageFile)}
className="shrink-0 h-10 w-10"
>
<Send className="size-5" />
</Button>
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { memo, useState, useEffect, useRef } from 'react';
import { cn, MODELS, PERMISSION_MODES } from '@/lib/utils';
import { Progress } from '@/components/ui/progress';
import { getBrand } from '@/lib/adapter-brands';
import { BottomSheet } from './BottomSheet';
export type AdapterConfig = {
models: { value: string; label: string; contextWindow: number }[];
permissionModes: { value: string; label: string }[];
capabilities?: {
supportsPermissionModes: boolean;
permissionModeType?: 'cycle' | 'toggle';
};
} | null;
export type SessionStatus = {
contextPercent: number | null;
model: string | null;
cost: number | null;
};
export const StatusBar = memo(function StatusBar({
model,
permissionMode,
sessionStatus,
adapterConfig,
selectedAdapter,
streaming,
onModelChange,
onPermissionModeChange,
}: {
model: string;
permissionMode: string;
sessionStatus: SessionStatus | null;
adapterConfig?: AdapterConfig;
selectedAdapter: string;
streaming?: boolean;
onModelChange: (m: string) => void;
onPermissionModeChange: (m: string) => void;
}) {
const [showModelPicker, setShowModelPicker] = useState(false);
const [slowNetwork, setSlowNetwork] = useState(false);
useEffect(() => {
const conn = (navigator as any).connection;
if (!conn) return;
const check = () => {
setSlowNetwork(conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g');
};
check();
conn.addEventListener('change', check);
return () => conn.removeEventListener('change', check);
}, []);
const brand = getBrand(selectedAdapter);
const models = adapterConfig?.models ?? MODELS;
const permissionModes = adapterConfig?.permissionModes ?? PERMISSION_MODES;
const modelConfig = models.find((m) => m.value === model);
const modelLabel = modelConfig?.label || model;
const modeLabel = permissionModes.find((m) => m.value === permissionMode)?.label
|| (permissionMode === 'plan' ? 'Plan' : permissionMode);
const contextPercent = sessionStatus?.contextPercent ?? 0;
const hasContext = contextPercent > 0;
const contextColor = contextPercent > 80 ? 'text-danger' : contextPercent > 50 ? 'text-warning' : 'text-text-dim';
const progressVariant = contextPercent > 80 ? 'danger' as const : contextPercent > 50 ? 'warning' as const : 'default' as const;
const canCycleMode = adapterConfig?.capabilities?.supportsPermissionModes !== false;
const modeType = adapterConfig?.capabilities?.permissionModeType || 'cycle';
// For toggle mode: remember the non-plan mode so we can toggle back to it.
// Can't use loadAdapterPrefs because updatePermissionMode writes 'plan' to prefs.
const startupModeRef = useRef(permissionMode !== 'plan' ? permissionMode : 'default');
if (permissionMode !== 'plan') {
startupModeRef.current = permissionMode;
}
const cycleMode = canCycleMode && !streaming ? () => {
if (modeType === 'toggle') {
// Codex: toggle between 'plan' and the startup mode
const nextMode = permissionMode === 'plan' ? startupModeRef.current : 'plan';
onPermissionModeChange(nextMode);
} else {
// Claude: cycle through full array
const idx = permissionModes.findIndex((m) => m.value === permissionMode);
const next = permissionModes[(idx + 1) % permissionModes.length];
onPermissionModeChange(next.value);
}
} : undefined;
return (
<div className="flex items-center justify-between px-4 py-1.5 text-[11px] text-text-dim font-mono border-t border-border">
<div className="flex items-center gap-2">
<span
className="text-[10px] font-semibold px-1.5 rounded"
style={{ color: brand.color, backgroundColor: brand.colorBg }}
>
{brand.displayName}
</span>
<button
onClick={() => !streaming && setShowModelPicker(true)}
className={`font-medium py-1 -my-1 transition-colors ${
streaming ? 'text-text-dim/50 cursor-default' : 'text-text-secondary hover:text-text cursor-pointer'
}`}
>
{modelLabel}
</button>
{slowNetwork && (
<span className="text-warning text-[10px] font-mono">Slow</span>
)}
<span className="text-border">·</span>
<button
onClick={cycleMode}
className={`text-text-dim transition-colors py-1 -my-1 ${
streaming ? 'text-text-dim/50 cursor-default'
: canCycleMode ? 'cursor-pointer hover:text-text' : 'cursor-default'
}`}
>
{modeLabel}
</button>
</div>
{hasContext && (
<div className="flex items-center gap-1.5">
<Progress value={contextPercent} variant={progressVariant} className="h-1 w-16" />
<span className={cn(contextColor)}>{contextPercent}%</span>
</div>
)}
<BottomSheet visible={showModelPicker} onClose={() => setShowModelPicker(false)} zIndex="z-40" backdropClassName="bg-black/60" className="p-4 pb-8">
<div className="text-sm font-medium text-text mb-3">Select Model</div>
<div className="space-y-1 max-h-[60vh] overflow-y-auto">
{models.map(m => (
<button
key={m.value}
onClick={() => { onModelChange(m.value); setShowModelPicker(false); }}
className={`w-full text-left px-3 py-2.5 rounded-md text-sm transition-colors ${
m.value === model
? 'bg-accent/20 text-accent font-medium'
: 'text-text-secondary hover:bg-white/5'
}`}
>
{m.label}
</button>
))}
</div>
</BottomSheet>
</div>
);
});
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react';
import { ToolCallCard } from './ToolCallCard';
import { Badge } from './ui/badge';
import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import type { ToolStatus } from '../hooks/useChat';
export function SubagentGroup({ agentTool, subTools, toolStatuses }: {
agentTool: any; subTools: any[]; toolStatuses: Map<string, ToolStatus>;
}) {
const [expanded, setExpanded] = useState(false);
const agentStatus = toolStatuses.get(agentTool.id);
const isRunning = agentStatus?.status === 'running';
const completedCount = subTools.filter((t) => {
const s = toolStatuses.get(t.id);
return s?.status === 'success' || s?.status === 'error';
}).length;
const statusText = isRunning ? `${completedCount}/${subTools.length} tools` : `${subTools.length} tools completed`;
return (
<div className="border-l-2 border-border pl-3 mb-3">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left"
>
<div className="flex items-center gap-2">
{isRunning ? (
<Loader2 className="size-3.5 text-accent animate-spin" />
) : (
<Badge variant="success">{completedCount}</Badge>
)}
<span className="font-mono text-xs text-text">{agentTool.name || 'Agent'}</span>
<span className="text-xs text-text-dim flex-1">{statusText}</span>
{expanded ? (
<ChevronUp className="size-3.5 text-text-dim" />
) : (
<ChevronDown className="size-3.5 text-text-dim" />
)}
</div>
{agentTool.input?.description && (
<div className="text-xs text-text-dim mt-1 ml-6">{agentTool.input.description}</div>
)}
</button>
{expanded && (
<div className="mt-2 space-y-2">
{subTools.map((tool) => {
const status = toolStatuses.get(tool.id);
return (
<div key={tool.id}>
<ToolCallCard
toolName={tool.name}
input={tool.input}
status={status?.status || 'running'}
result={status?.result}
/>
</div>
);
})}
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
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>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useState } from 'react';
import { Loader2, Check, X, Ban, ChevronDown, ChevronUp } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { DiffViewer } from './DiffViewer';
const STATUS_CONFIG: Record<string, { icon: React.ReactNode }> = {
running: { icon: <Loader2 className="size-4 animate-spin text-text-dim" /> },
success: { icon: <Check className="size-4 text-success" /> },
error: { icon: <X className="size-4 text-danger" /> },
interrupted: { icon: <Ban className="size-4 text-text-dim" /> },
};
function toolSummary(toolName: string, input: any): string {
if (toolName === 'Bash' && input?.command) {
return input.command.length > 60 ? input.command.slice(0, 60) + '...' : input.command;
}
if ((toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') && input?.file_path) {
return input.file_path;
}
if (toolName === 'Glob' && input?.pattern) return input.pattern;
if (toolName === 'Grep' && input?.pattern) return input.pattern;
if ((toolName === 'Agent' || toolName === 'Task') && input?.description) return input.description;
if (toolName === 'WebFetch' && input?.url) return input.url;
if (toolName === 'WebSearch' && input?.query) return input.query;
// Fallback: return first non-empty string value from input (notchi pattern)
if (input && typeof input === 'object') {
for (const value of Object.values(input)) {
if (typeof value === 'string' && value.trim()) {
return value.length > 80 ? value.slice(0, 80) + '...' : value;
}
}
}
return '';
}
function hasDiff(toolName: string, input: any): boolean {
return toolName === 'Edit' && input?.old_string != null && input?.new_string != null;
}
function hasNewFile(toolName: string): boolean {
return toolName === 'Write';
}
function getResultText(result: any): string | null {
if (!result) return null;
if (typeof result.content === 'string') return result.content;
if (typeof result === 'string') return result;
if (result.content) return JSON.stringify(result.content, null, 2);
return null;
}
export function ToolCallCard({ toolName, input, status, result }: {
toolName: string; input: any; status: 'running' | 'success' | 'error' | 'interrupted'; result?: any;
}) {
const [expanded, setExpanded] = useState(false);
const [showFullDiff, setShowFullDiff] = useState(false);
const { icon } = STATUS_CONFIG[status];
const summary = toolSummary(toolName, input);
const isDiff = hasDiff(toolName, input);
const isNewFile = hasNewFile(toolName);
return (
<>
<div className="mb-2 border-l-2 border-accent/30">
<button
onClick={() => setExpanded(!expanded)}
className={cn(
'w-full text-left hover:bg-surface/50 px-3 py-2 transition-colors',
expanded ? 'rounded-t-md' : 'rounded-md',
)}
>
<div className="flex items-center gap-2">
{icon}
<Badge variant="mono">{toolName}</Badge>
{summary && <span className="text-xs text-text-dim truncate flex-1">{summary}</span>}
{expanded
? <ChevronUp className="size-3.5 text-text-dim shrink-0" />
: <ChevronDown className="size-3.5 text-text-dim shrink-0" />
}
</div>
{!expanded && isDiff && (
<div className="mt-1.5 font-mono text-xs leading-relaxed overflow-hidden max-h-20">
{input.old_string?.split('\n').slice(0, 2).map((line: string, i: number) => (
<div key={`d-${i}`} className="text-danger/70 truncate">- {line}</div>
))}
{input.new_string?.split('\n').slice(0, 2).map((line: string, i: number) => (
<div key={`a-${i}`} className="text-success/70 truncate">+ {line}</div>
))}
</div>
)}
</button>
{expanded && (
<div className="bg-surface/30 rounded-b-md px-3 py-2 text-xs font-mono overflow-x-auto">
{isDiff ? (
<>
<div className="space-y-0.5 max-h-48 overflow-y-auto">
{input.old_string?.split('\n').map((line: string, i: number) => (
<div key={`d-${i}`} className="text-danger">- {line}</div>
))}
{input.new_string?.split('\n').map((line: string, i: number) => (
<div key={`a-${i}`} className="text-success">+ {line}</div>
))}
</div>
<button onClick={() => setShowFullDiff(true)} className="text-accent-light text-xs mt-2 hover:underline">View full diff</button>
</>
) : isNewFile ? (
<div className="max-h-48 overflow-y-auto">
<div className="text-text-dim mb-1">{input.file_path}</div>
<pre className="text-text whitespace-pre-wrap">{input.content?.slice(0, 500)}</pre>
{input.content?.length > 500 && <div className="text-text-dim mt-1">...{input.content.split('\n').length} lines</div>}
</div>
) : (() => {
const resultStr = getResultText(result);
if (toolName === 'Read' && input?.file_path) return (
<div className="max-h-48 overflow-y-auto">
<div className="text-text-dim mb-1 truncate">{input.file_path}</div>
{resultStr && <pre className="text-text whitespace-pre-wrap text-[11px] leading-relaxed">{resultStr.slice(0, 2000)}</pre>}
</div>
);
if (toolName === 'Bash') return (
<div className="max-h-48 overflow-y-auto">
{input?.description && <div className="text-text-dim mb-1">{input.description}</div>}
<pre className="text-accent-light whitespace-pre-wrap mb-2">$ {input?.command}</pre>
{resultStr && (
<>
<div className="text-text-dim mb-1">Output:</div>
<pre className="text-text whitespace-pre-wrap text-[11px]">{resultStr.slice(0, 2000)}</pre>
</>
)}
</div>
);
if ((toolName === 'Grep' || toolName === 'Glob') && input?.pattern) return (
<div className="max-h-48 overflow-y-auto">
<div className="text-text-dim mb-1">
Pattern: <span className="text-accent-light">{input.pattern}</span>
{input.path && <span className="ml-2">in {input.path}</span>}
</div>
{resultStr && <pre className="text-text whitespace-pre-wrap text-[11px]">{resultStr.slice(0, 2000)}</pre>}
</div>
);
if ((toolName === 'Agent' || toolName === 'Task') && input?.description) return (
<div className="max-h-48 overflow-y-auto">
<div className="text-text mb-1 font-medium">{input.description}</div>
{input.prompt && (
<pre className="text-text-dim whitespace-pre-wrap text-[11px] mt-1">{input.prompt.slice(0, 500)}{input.prompt.length > 500 ? '...' : ''}</pre>
)}
{resultStr && (
<>
<div className="text-text-dim mb-1 mt-2">Result:</div>
<pre className="text-text whitespace-pre-wrap text-[11px]">{resultStr.slice(0, 1000)}</pre>
</>
)}
</div>
);
return (
<>
<div className="text-text-dim mb-1">Input:</div>
<pre className="text-text whitespace-pre-wrap mb-2 max-h-32 overflow-y-auto">{JSON.stringify(input, null, 2)}</pre>
{resultStr && (
<>
<div className="text-text-dim mb-1">Output:</div>
<pre className="text-text whitespace-pre-wrap max-h-32 overflow-y-auto">{resultStr.slice(0, 1000)}</pre>
</>
)}
</>
);
})()}
</div>
)}
</div>
{showFullDiff && isDiff && (
<DiffViewer filePath={input.file_path} oldString={input.old_string} newString={input.new_string} onClose={() => setShowFullDiff(false)} />
)}
</>
);
}
@@ -0,0 +1,46 @@
// src/components/adapters/claude/InsightBlock.tsx
import { useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { cn } from '@/lib/utils';
export function InsightBlock({ text }: { text: string }) {
const [expanded, setExpanded] = useState(false);
const summary = text.split('\n').find(l => l.trim())?.trim() || 'Insight';
const truncated = summary.length > 80 ? summary.slice(0, 80) + '...' : summary;
return (
<div className="my-2">
<button
onClick={() => setExpanded(!expanded)}
className={cn(
'w-full text-left px-3 py-2 transition-colors',
'bg-surface/30 border border-border/50 hover:bg-surface/60',
expanded ? 'rounded-t-md' : 'rounded-md',
)}
>
<div className="flex items-center gap-2">
<span className="text-accent-light text-sm shrink-0"></span>
<span className="text-xs text-accent-light font-medium shrink-0">Insight</span>
{!expanded && (
<span className="text-xs text-text-dim truncate flex-1">{truncated}</span>
)}
{expanded
? <ChevronUp className="size-3.5 text-text-dim shrink-0 ml-auto" />
: <ChevronDown className="size-3.5 text-text-dim shrink-0 ml-auto" />
}
</div>
</button>
{expanded && (
<div className={cn(
'bg-surface/20 border border-t-0 border-border/50 rounded-b-md px-3 py-2',
'prose prose-invert prose-sm max-w-none',
'[&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0.5',
'[&_code]:text-accent-light [&_code]:text-xs',
)}>
<ReactMarkdown>{text}</ReactMarkdown>
</div>
)}
</div>
);
}
@@ -0,0 +1,16 @@
import type { TextPattern } from '@/lib/text-transforms';
/**
* Claude Code text patterns for special content rendering.
*
* Insight format:
* `★ Insight ─────────────────────────────────────`
* [content lines]
* `─────────────────────────────────────────────────`
*/
export const CLAUDE_PATTERNS: TextPattern[] = [
{
type: 'insight',
regex: /`[★✦]?\s*Insight\s*[─\-]+`\n([\s\S]*?)\n`[─\-]+[.。]?`/g,
},
];
+15
View File
@@ -0,0 +1,15 @@
/**
* ClawAscii — ASCII art lobster claw for login/branding screens.
* Uses block drawing characters matching the terminal aesthetic.
*/
export function ClawAscii({ className }: { className?: string }) {
return (
<pre className={`text-accent font-mono text-sm leading-tight select-none ${className || ''}`}>
{`▐▌ ▐▌
▐█ █▌
▐██▄▄██▌
▀████▀
██`}
</pre>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { cn } from '@/lib/utils';
/**
* LoadingAnimation — SVG pixel claw (8×6) that walks right and eats dots.
* Pincers pivot from the root joint with 12° open/close.
*/
const sizeConfig = {
sm: { svgW: 24, svgH: 18, dotSize: 3, dotGap: 5, dots: 3, height: 'h-8' },
md: { svgW: 48, svgH: 36, dotSize: 4, dotGap: 8, dots: 5, height: 'h-12' },
lg: { svgW: 64, svgH: 48, dotSize: 5, dotGap: 10, dots: 6, height: 'h-16' },
} as const;
interface Props {
size?: 'sm' | 'md' | 'lg';
label?: string;
className?: string;
}
function PixelClaw({ width, height }: { width: number; height: number }) {
return (
<svg
width={width}
height={height}
viewBox="0 0 8 6"
style={{ imageRendering: 'pixelated' }}
className="claw-svg"
>
{/* Top pincer — pivots from root (3,2) */}
<g className="claw-pincer-top">
<rect x="4" y="0" width="4" height="1" fill="#22c55e" />
<rect x="3" y="1" width="2" height="1" fill="#22c55e" />
</g>
{/* Body */}
<rect x="0" y="2" width="4" height="2" fill="#22c55e" />
<rect x="4" y="2" width="1" height="2" fill="#4ade80" opacity="0.5" />
{/* Bottom pincer — pivots from root (3,4) */}
<g className="claw-pincer-bot">
<rect x="3" y="4" width="2" height="1" fill="#22c55e" />
<rect x="4" y="5" width="4" height="1" fill="#22c55e" />
</g>
</svg>
);
}
export function LoadingAnimation({ size = 'md', label, className }: Props) {
const cfg = sizeConfig[size];
return (
<div className={cn('flex flex-col items-center gap-3', className)}>
<div className={cn('relative flex items-center justify-center overflow-hidden', cfg.height)}>
<div className="flex items-center">
{/* Claw walks right */}
<div className="claw-walk">
<PixelClaw width={cfg.svgW} height={cfg.svgH} />
</div>
{/* Dots that get eaten */}
<div className="flex items-center" style={{ gap: cfg.dotGap, marginLeft: 4 }}>
{Array.from({ length: cfg.dots }).map((_, i) => (
<div
key={i}
className="claw-dot rounded-full"
style={{
width: cfg.dotSize,
height: cfg.dotSize,
background: 'rgba(34, 197, 94, 0.35)',
animationDelay: `${i * 0.35}s`,
}}
/>
))}
</div>
</div>
</div>
{label && (
<span className="text-xs text-text-dim font-mono animate-pulse">{label}</span>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium',
{
variants: {
variant: {
default: 'bg-accent/15 text-accent-light',
secondary: 'bg-surface-light text-text-dim',
success: 'bg-success/15 text-success',
destructive: 'bg-danger/15 text-danger',
warning: 'bg-warning/15 text-warning',
outline: 'border border-border text-text-dim',
mono: 'bg-surface-light text-text-secondary font-mono',
terminal: 'border border-accent/30 text-accent bg-accent/5 font-mono tracking-wider uppercase',
},
},
defaultVariants: { variant: 'default' },
}
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+43
View File
@@ -0,0 +1,43 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40 cursor-pointer',
{
variants: {
variant: {
default: 'bg-accent text-bg hover:bg-accent-light',
destructive: 'bg-danger text-white hover:bg-danger/80',
outline: 'border border-border bg-transparent text-text hover:bg-surface-light',
secondary: 'bg-surface-light text-text hover:bg-border',
ghost: 'text-text-dim hover:text-text hover:bg-surface-light',
link: 'text-accent underline-offset-4 hover:underline',
terminal: 'bg-transparent border border-accent text-accent hover:bg-accent/10 font-mono',
},
size: {
default: 'h-9 px-3 py-2',
sm: 'h-8 px-2.5 text-xs',
lg: 'h-10 px-4',
icon: 'h-8 w-8 p-0',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+35
View File
@@ -0,0 +1,35 @@
import { cn } from '@/lib/utils';
interface PillOption {
readonly value: string;
readonly label: string;
}
export function PillSelector<T extends PillOption>({
options,
value,
onChange,
}: {
options: readonly T[];
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="flex gap-1 bg-surface rounded-md p-0.5">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
className={cn(
'px-2 py-1 rounded text-xs font-medium font-mono tracking-wide transition-colors cursor-pointer',
value === opt.value
? 'bg-accent/20 text-accent'
: 'text-text-dim hover:text-text',
)}
>
{opt.label}
</button>
))}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number;
variant?: 'default' | 'success' | 'warning' | 'danger';
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, variant = 'default', ...props }, ref) => {
const colors = {
default: 'bg-accent',
success: 'bg-success',
warning: 'bg-warning',
danger: 'bg-danger',
};
return (
<div ref={ref} className={cn('h-1 w-full overflow-hidden rounded-sm bg-surface-light', className)} {...props}>
<div
className={cn('h-full rounded-sm transition-all duration-300', colors[variant])}
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
);
}
);
Progress.displayName = 'Progress';
export { Progress };
+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,
};
}
+75
View File
@@ -0,0 +1,75 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../lib/api';
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray.buffer as ArrayBuffer;
}
export function usePushNotifications() {
const [permission, setPermission] = useState<NotificationPermission>(
typeof Notification !== 'undefined' ? Notification.permission : 'denied'
);
const [subscribed, setSubscribed] = useState(false);
const [supported] = useState(() =>
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
// Check existing subscription on mount
useEffect(() => {
if (!supported) return;
navigator.serviceWorker.ready.then(reg => {
reg.pushManager.getSubscription().then(sub => {
setSubscribed(!!sub);
});
});
}, [supported]);
const subscribe = useCallback(async () => {
if (!supported) return false;
const perm = await Notification.requestPermission();
setPermission(perm);
if (perm !== 'granted') return false;
const reg = await navigator.serviceWorker.ready;
// Get VAPID public key from server
const { publicKey } = await api.vapidPublicKey();
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
}
// Send subscription to server
await api.pushSubscribe(sub.toJSON());
setSubscribed(true);
return true;
}, [supported]);
const unsubscribe = useCallback(async () => {
if (!supported) return;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
await api.pushUnsubscribe(sub.endpoint);
await sub.unsubscribe();
}
setSubscribed(false);
}, [supported]);
return { supported, permission, subscribed, subscribe, unsubscribe };
}
+108
View File
@@ -0,0 +1,108 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { api } from '../lib/api';
import { STORAGE } from '../lib/storage-keys';
export function useSessions() {
const [allSessions, setAllSessions] = useState<any[]>([]);
const [selectedProjectDir, setSelectedProjectDir] = useState<string | null>(
() => localStorage.getItem(STORAGE.PROJECT_DIR)
);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'projects' | 'active'>('projects');
const [activeSessions, setActiveSessions] = useState<any[]>([]);
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const sessionsData = await api.sessions(undefined, 200);
setAllSessions(sessionsData);
} catch (err) {
console.error('Failed to fetch sessions:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const fetchActiveSessions = useCallback(async () => {
try {
const data = await api.activeSessions();
// Skip setState if data unchanged — prevents re-render every 10s poll
setActiveSessions(prev =>
JSON.stringify(prev) === JSON.stringify(data) ? prev : data
);
} catch (err) {
console.error('Failed to fetch active sessions:', err);
}
}, []);
// Poll every 10s when Active tab is selected
useEffect(() => {
if (activeTab !== 'active') return;
fetchActiveSessions();
const interval = setInterval(fetchActiveSessions, 3000);
return () => clearInterval(interval);
}, [activeTab, fetchActiveSessions]);
// Fetch once on mount for green dots in project view
useEffect(() => {
fetchActiveSessions();
}, [fetchActiveSessions]);
const activeSessionIds = useMemo(() => {
const ids = new Set<string>();
for (const s of activeSessions) {
if (s.sessionId) ids.add(s.sessionId);
}
return ids;
}, [activeSessions]);
const projects = useMemo(() => {
const cwds = new Set<string>();
for (const s of allSessions) {
if (s.cwd) cwds.add(s.cwd);
}
return [...cwds].sort();
}, [allSessions]);
const sessionCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of allSessions) {
const dir = s.cwd || '';
counts[dir] = (counts[dir] || 0) + 1;
}
return counts;
}, [allSessions]);
const filteredSessions = useMemo(() => {
if (!selectedProjectDir) return [];
return allSessions.filter((s) => s.cwd === selectedProjectDir);
}, [allSessions, selectedProjectDir]);
const selectProject = useCallback((dir: string | null) => {
setSelectedProjectDir(dir);
if (dir) {
localStorage.setItem(STORAGE.PROJECT_DIR, dir);
} else {
localStorage.removeItem(STORAGE.PROJECT_DIR);
}
}, []);
return {
sessions: filteredSessions,
projects,
selectedProjectDir,
selectProject,
sessionCounts,
loading,
refresh: fetchAll,
activeTab,
setActiveTab,
activeSessions,
activeSessionIds,
refreshActive: fetchActiveSessions,
};
}
+66
View File
@@ -0,0 +1,66 @@
import { useState, useCallback, useRef } from 'react';
export function useVoiceInput(onTranscript: (text: string) => void) {
const [isRecording, setIsRecording] = useState(false);
const [interimText, setInterimText] = useState('');
const recognitionRef = useRef<any>(null);
const supported =
typeof window !== 'undefined' &&
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) &&
window.isSecureContext;
const startRecording = useCallback(() => {
if (!supported) return;
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'zh-TW';
recognition.onresult = (event: any) => {
let interim = '';
let final = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
final += transcript;
} else {
interim += transcript;
}
}
if (final) onTranscript(final);
setInterimText(interim);
};
recognition.onerror = (event: any) => {
if (event.error !== 'aborted') console.error('Speech recognition error:', event.error);
setIsRecording(false);
setInterimText('');
};
recognition.onend = () => {
setIsRecording(false);
setInterimText('');
};
recognitionRef.current = recognition;
recognition.start();
setIsRecording(true);
}, [onTranscript, supported]);
const stopRecording = useCallback(() => {
recognitionRef.current?.stop();
recognitionRef.current = null;
setIsRecording(false);
setInterimText('');
}, []);
const toggleRecording = useCallback(() => {
if (isRecording) stopRecording();
else startRecording();
}, [isRecording, startRecording, stopRecording]);
return { isRecording, interimText, toggleRecording, supported };
}
+159
View File
@@ -0,0 +1,159 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@theme {
--color-bg: #09090b;
--color-surface: #18181b;
--color-surface-light: #27272a;
--color-border: #3f3f46;
--color-accent: #22c55e;
--color-accent-light: #4ade80;
--color-accent-glow: rgba(34, 197, 94, 0.15);
--color-user-bubble: rgba(5, 46, 22, 0.8);
--color-user-bubble-text: #4ade80;
--color-text: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-dim: #71717a;
--color-success: #22c55e;
--color-danger: #ef4444;
--color-warning: #eab308;
--color-surface-hover: #1f1f23;
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-mono);
font-size: 14px;
-webkit-font-smoothing: antialiased;
overscroll-behavior: none;
}
@keyframes shimmer {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes pulse-dot {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.shimmer-text {
background: linear-gradient(90deg, #22c55e, #22d3ee, #a78bfa, #22c55e);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: shimmer 2s linear infinite;
}
.cursor-blink::after {
content: '\258b';
animation: blink 1s step-end infinite;
color: var(--color-accent);
}
.typing-dot {
animation: pulse-dot 1.4s infinite ease-in-out both;
}
.typing-dot:nth-child(2) { animation-delay: 0.16s; }
.typing-dot:nth-child(3) { animation-delay: 0.32s; }
.safe-bottom {
padding-bottom: max(12px, env(safe-area-inset-bottom));
}
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-x {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
input, textarea, select {
font-size: 16px;
}
/* Review panel uses smaller text to fit the compact layout.
16px stays on main input to prevent iOS Safari auto-zoom. */
.review-panel-compact textarea {
font-size: 14px;
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 2px; }
/* ClawTap loading — SVG pixel claw walks right, pincers pivot from root */
.claw-svg {
filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.3));
}
.claw-pincer-top {
transform-origin: 3px 2px;
animation: claw-pivot-top 0.4s ease-in-out infinite;
}
.claw-pincer-bot {
transform-origin: 3px 4px;
animation: claw-pivot-bot 0.4s ease-in-out infinite;
}
@keyframes claw-pivot-top {
0%, 100% { transform: rotate(-12deg); }
50% { transform: rotate(0deg); }
}
@keyframes claw-pivot-bot {
0%, 100% { transform: rotate(12deg); }
50% { transform: rotate(0deg); }
}
.claw-walk {
animation: claw-walk 2.2s linear infinite;
}
@keyframes claw-walk {
0% { transform: translateX(-24px); }
100% { transform: translateX(8px); }
}
.claw-dot {
animation: claw-dot-eat 2.2s ease-in-out infinite;
}
@keyframes claw-dot-eat {
0%, 50% { opacity: 0.5; transform: scale(1); }
65% { opacity: 0; transform: scale(0); }
80%, 100% { opacity: 0.5; transform: scale(1); }
}
/* ClawTap logo float */
.claw-float {
animation: claw-float 3s ease-in-out infinite;
}
@keyframes claw-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
/* Terminal glow effect */
.text-glow {
text-shadow: 0 0 8px var(--color-accent-glow);
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
+47
View File
@@ -0,0 +1,47 @@
export interface AdapterBrand {
id: string;
displayName: string;
provider: string;
color: string;
colorBg: string;
gradient: string;
glow: string;
iconType: 'claude' | 'codex' | 'gemini';
}
export const ADAPTER_BRANDS: Record<string, AdapterBrand> = {
claude: {
id: 'claude',
displayName: 'Claude',
provider: 'Anthropic',
color: '#d97706',
colorBg: '#d9770622',
gradient: 'linear-gradient(135deg, #d97706, #a85e04)',
glow: 'rgba(217,119,6,0.3)',
iconType: 'claude',
},
codex: {
id: 'codex',
displayName: 'Codex',
provider: 'OpenAI',
color: '#10b981',
colorBg: '#10b98122',
gradient: 'linear-gradient(135deg, #10b981, #047857)',
glow: 'rgba(16,185,129,0.3)',
iconType: 'codex',
},
gemini: {
id: 'gemini',
displayName: 'Gemini',
provider: 'Google',
color: '#4285f4',
colorBg: '#4285f422',
gradient: 'linear-gradient(135deg, #4285f4, #1a73e8)',
glow: 'rgba(66,133,244,0.3)',
iconType: 'gemini',
},
};
export function getBrand(adapterId: string): AdapterBrand {
return ADAPTER_BRANDS[adapterId] || ADAPTER_BRANDS.claude;
}
+23
View File
@@ -0,0 +1,23 @@
import { STORAGE } from './storage-keys';
export type AdapterPrefs = { model: string; permissionMode: string; effort?: string };
export function loadAdapterPrefs(adapterId: string): AdapterPrefs {
try {
const raw = localStorage.getItem(STORAGE.adapterPrefs(adapterId));
if (raw) return JSON.parse(raw);
} catch {}
// Defaults per adapter
if (adapterId === 'claude') return { model: 'opus[1m]', permissionMode: 'default', effort: 'high' };
if (adapterId === 'codex') return { model: 'gpt-5.4', permissionMode: 'default', effort: 'high' };
return { model: '', permissionMode: 'default', effort: 'high' };
}
export function saveAdapterPrefs(adapterId: string, prefs: AdapterPrefs): void {
localStorage.setItem(STORAGE.adapterPrefs(adapterId), JSON.stringify(prefs));
}
export function patchAdapterPrefs(adapterId: string, patch: Partial<AdapterPrefs>): void {
const existing = loadAdapterPrefs(adapterId);
saveAdapterPrefs(adapterId, { ...existing, ...patch });
}
+157
View File
@@ -0,0 +1,157 @@
import { STORAGE } from './storage-keys';
const BASE = '';
function getToken(): string | null {
return localStorage.getItem(STORAGE.TOKEN);
}
export function setToken(token: string) {
localStorage.setItem(STORAGE.TOKEN, token);
}
export function clearToken() {
localStorage.removeItem(STORAGE.TOKEN);
}
export function isAuthenticated(): boolean {
return !!getToken();
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${BASE}${path}`, { ...options, headers });
const refreshed = res.headers.get('X-Refreshed-Token');
if (refreshed) {
setToken(refreshed);
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
return res.json();
}
export const api = {
login: (password: string) =>
request<{ token: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
}),
sessions: (dir?: string, limit?: number) => {
const params = new URLSearchParams();
if (dir) params.set('dir', dir);
if (limit) params.set('limit', String(limit));
const qs = params.toString();
return request<any[]>(`/api/sessions${qs ? `?${qs}` : ''}`);
},
sessionMessages: (id: string, dir?: string) => {
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : '';
return request<{ messages: any[]; lastModified: string | null }>(`/api/sessions/${id}/messages${qs}`);
},
uploadImage: async (file: File): Promise<{ path: string; filename: string }> => {
const token = getToken();
const form = new FormData();
form.append('image', file);
const res = await fetch('/api/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('Upload failed');
return res.json();
},
browse: (path?: string) => {
const qs = path ? `?path=${encodeURIComponent(path)}` : '';
return request<{ name: string; path: string; hasChildren: boolean }[]>(
`/api/browse${qs}`
);
},
adapters: () =>
request<{ id: string; displayName: string; available: boolean }[]>('/api/adapters'),
adapterConfig: (name: string) =>
request<{
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';
};
}>(`/api/adapter/${name}/config`),
activeSessions: () =>
request<any[]>('/api/active-sessions'),
destroySession: (sessionId: string, adapter?: string) => {
const qs = adapter ? `?adapter=${adapter}` : '';
return request<{ ok: boolean }>(`/api/active-sessions/${sessionId}${qs}`, { method: 'DELETE' });
},
vapidPublicKey: () =>
request<{ publicKey: string }>('/api/push/vapid-public-key'),
pushSubscribe: (subscription: PushSubscriptionJSON) =>
request<{ ok: boolean }>('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify({ subscription }),
}),
pushUnsubscribe: (endpoint: string) =>
request<{ ok: boolean }>('/api/push/unsubscribe', {
method: 'POST',
body: JSON.stringify({ endpoint }),
}),
pushPending: () =>
request<Record<string, number>>('/api/push/pending'),
registerReview: (parentCliSessionId: string, childSessionId: string, targetAdapter: string, anchorMessageId: string, prompt: string, title: string) =>
request<{ reviewId: string }>('/api/reviews/register', {
method: 'POST',
body: JSON.stringify({ parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title }),
}),
endReview: (reviewId: string, endAnchorMessageId?: string) =>
request<{ ok: boolean }>(`/api/reviews/${reviewId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endAnchorMessageId }),
}),
sendBackToParent: (reviewId: string, message: string) =>
request<{ ok: boolean }>(`/api/reviews/${reviewId}/send-back`, { method: 'POST', body: JSON.stringify({ message }) }),
getReviews: (parentCliSessionId: string) =>
request<any[]>(`/api/reviews?parentCliSessionId=${encodeURIComponent(parentCliSessionId)}`),
getInstructions: () =>
request<{ id: string; label: string; instruction: string; created_at: string }[]>('/api/instructions'),
createInstruction: (label: string, instruction: string) =>
request<{ id: string; label: string; instruction: string }>('/api/instructions', {
method: 'POST',
body: JSON.stringify({ label, instruction }),
}),
deleteInstruction: (id: string) =>
request<void>(`/api/instructions/${id}`, { method: 'DELETE' }),
};
+9
View File
@@ -0,0 +1,9 @@
export function extractTextFromBlocks(content: any[]): string {
return content.map((b: any) => typeof b === 'string' ? b : b.text || '').join('\n');
}
const CLAWTAP_REF_REGEX = /^\[CLAWTAP_REF:[^\]]+\](?:\\n|\n)?/;
export function stripMarker(text: string): string {
return text.replace(CLAWTAP_REF_REGEX, '');
}
+46
View File
@@ -0,0 +1,46 @@
/** Centralized localStorage key constants. All keys use the `clawtap:` prefix. */
export const STORAGE = {
TOKEN: 'clawtap:token',
ADAPTER: 'clawtap:adapter',
PROJECT_DIR: 'clawtap:projectDir',
DRAFT: 'clawtap:draft',
INSTALL_DISMISSED: 'clawtap:install-dismissed',
adapterPrefs: (id: string) => `clawtap:adapterPrefs:${id}` as const,
} as const;
/** One-time migration from old key names. Runs once before app mount. */
export function migrateStorageKeys(): void {
// Rename simple keys
for (const [oldKey, newKey] of [
['token', STORAGE.TOKEN],
['selectedProjectDir', STORAGE.PROJECT_DIR],
] as const) {
const val = localStorage.getItem(oldKey);
if (val !== null && localStorage.getItem(newKey) === null) {
localStorage.setItem(newKey, val);
localStorage.removeItem(oldKey);
}
}
// Migrate old global model/permissionMode/effort into per-adapter prefs
// Check both old bare keys AND clawtap:-prefixed keys (from intermediate migration)
const oldModel = localStorage.getItem('selectedModel') || localStorage.getItem('clawtap:model');
const oldMode = localStorage.getItem('permissionMode') || localStorage.getItem('clawtap:permissionMode');
const oldEffort = localStorage.getItem('effort') || localStorage.getItem('clawtap:effort');
if (oldModel || oldMode || oldEffort) {
const adapter = localStorage.getItem(STORAGE.ADAPTER) || 'claude';
const prefsKey = STORAGE.adapterPrefs(adapter);
let prefs: Record<string, string> = {};
try { prefs = JSON.parse(localStorage.getItem(prefsKey) || '{}'); } catch {}
if (oldModel && !prefs.model) prefs.model = oldModel;
if (oldMode && !prefs.permissionMode) prefs.permissionMode = oldMode;
if (oldEffort && !prefs.effort) prefs.effort = oldEffort;
localStorage.setItem(prefsKey, JSON.stringify(prefs));
localStorage.removeItem('selectedModel');
localStorage.removeItem('permissionMode');
localStorage.removeItem('effort');
localStorage.removeItem('clawtap:model');
localStorage.removeItem('clawtap:permissionMode');
localStorage.removeItem('clawtap:effort');
}
}
+53
View File
@@ -0,0 +1,53 @@
export interface TextPattern {
type: string;
regex: RegExp;
}
export interface TextSegment {
type: string;
text: string;
}
export function splitTextSegments(text: string, patterns: TextPattern[]): TextSegment[] {
if (!text || patterns.length === 0) return [{ type: 'markdown', text }];
// Fast pre-check: skip regex if text has no backticks (insight delimiters use backticks)
if (!text.includes('`')) return [{ type: 'markdown', text }];
const matches: { type: string; start: number; end: number; captured: string }[] = [];
for (const pattern of patterns) {
pattern.regex.lastIndex = 0; // Reset stateful /g flag instead of cloning
let m: RegExpExecArray | null;
while ((m = pattern.regex.exec(text)) !== null) {
matches.push({
type: pattern.type,
start: m.index,
end: m.index + m[0].length,
captured: m[1] ?? m[0],
});
}
}
if (matches.length === 0) return [{ type: 'markdown', text }];
matches.sort((a, b) => a.start - b.start);
const segments: TextSegment[] = [];
let cursor = 0;
for (const match of matches) {
if (match.start < cursor) continue;
if (match.start > cursor) {
const before = text.slice(cursor, match.start).trim();
if (before) segments.push({ type: 'markdown', text: before });
}
segments.push({ type: match.type, text: match.captured.trim() });
cursor = match.end;
}
if (cursor < text.length) {
const after = text.slice(cursor).trim();
if (after) segments.push({ type: 'markdown', text: after });
}
return segments;
}
+46
View File
@@ -0,0 +1,46 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function timeAgo(timestamp: number | string): string {
const time = typeof timestamp === 'number' ? timestamp : new Date(timestamp).getTime();
const diff = Date.now() - time;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
export function dirName(path: string): string {
return path.split('/').pop() || path;
}
export function formatTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return String(n);
}
// Source of truth: server/adapters/claude/index.js
// These are duplicated here for UI convenience. Keep in sync with the server constants.
export const MODELS = [
{ value: 'sonnet', label: 'Sonnet', contextWindow: 200000 },
{ value: 'opus', label: 'Opus', contextWindow: 200000 },
{ value: 'haiku', label: 'Haiku', contextWindow: 200000 },
{ value: 'opus[1m]', label: 'Opus 1M', contextWindow: 1000000 },
{ value: 'sonnet[1m]', label: 'Sonnet 1M', contextWindow: 1000000 },
] as const;
// Source of truth: server/adapters/claude/index.js — keep in sync.
export const PERMISSION_MODES = [
{ value: 'default', label: 'Normal' },
{ value: 'acceptEdits', label: 'Auto-edit' },
{ value: 'plan', label: 'Plan' },
{ value: 'bypassPermissions', label: 'YOLO' },
] as const;
+49
View File
@@ -0,0 +1,49 @@
export const WS = {
// Client → Server
QUERY: 'query',
PERMISSION_RESPONSE: 'permission-response',
ASK_RESPONSE: 'ask-response',
ABORT: 'abort',
RECONNECT: 'reconnect',
SET_PERMISSION_MODE: 'set-permission-mode',
SET_MODEL: 'set-model',
PLAN_RESPONSE: 'plan-response',
// Server → Client
SESSION_CREATED: 'session-created',
TEXT_DELTA: 'text-delta',
THINKING: 'thinking',
TOOL_START: 'tool-start',
TOOL_DONE: 'tool-done',
MESSAGE_COMPLETE: 'message-complete',
TOOL_UPDATES: 'tool-updates',
TURN_COMPLETE: 'turn-complete',
PERMISSION_REQUEST: 'permission-request',
PERMISSION_DISMISSED: 'permission-dismissed',
HISTORY_LOAD: 'history-load',
STATUS_UPDATE: 'status-update',
MODE_UPDATED: 'mode-updated',
COMPACTING: 'compacting',
COMPACT_DONE: 'compact-done',
SESSION_ERROR: 'session-error',
SESSION_STATE: 'session-state',
SESSION_ENDED: 'session-ended',
CLIENT_ID: 'client-id',
PENDING_NOTIFICATIONS: 'pending-notifications',
ERROR: 'error',
// Cross-AI Review
REVIEW_STARTED: 'review-started',
REVIEW_ENDED: 'review-ended',
} as const;
/**
* CLI plan approval selector option indices (ExitPlanMode).
* Claude Code v2.1.x shows 3 options:
* 0: "Yes, auto-accept edits" → BYPASS
* 1: "Yes, manually approve edits" → MANUALLY_APPROVE
* 2: "Type here to tell Claude what to change" → TEXT_FEEDBACK
*/
export const PLAN_OPTION = {
BYPASS: 0,
MANUALLY_APPROVE: 1,
TEXT_FEEDBACK: 2,
} as const;
+81
View File
@@ -0,0 +1,81 @@
import { WS } from './ws-types';
export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
type MessageHandler = (msg: any) => void;
type StatusHandler = (status: WsStatus) => void;
export class WsClient {
private ws: WebSocket | null = null;
private url: string;
private onMessage: MessageHandler;
private onStatus: StatusHandler;
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
private shouldReconnect = true;
private activeSessionId: string | null = null;
private activeAdapter: string | null = null;
constructor(token: string, onMessage: MessageHandler, onStatus: StatusHandler) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
this.url = `${proto}://${location.host}/ws?token=${encodeURIComponent(token)}`;
this.onMessage = onMessage;
this.onStatus = onStatus;
}
connect() {
this.shouldReconnect = true;
this.onStatus('connecting');
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectDelay = 1000;
this.onStatus('connected');
if (this.activeSessionId) {
this.send({ type: WS.RECONNECT, sessionId: this.activeSessionId, adapter: this.activeAdapter });
}
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === WS.SESSION_CREATED) {
this.activeSessionId = msg.sessionId;
}
this.onMessage(msg);
} catch {}
};
this.ws.onclose = () => {
this.onStatus('disconnected');
if (this.shouldReconnect) {
this.onStatus('reconnecting');
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
};
this.ws.onerror = () => {
this.ws?.close();
};
}
send(msg: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
setActiveSession(sessionId: string | null, adapter?: string) {
this.activeSessionId = sessionId;
this.activeAdapter = adapter || null;
}
disconnect() {
this.shouldReconnect = false;
this.ws?.close();
this.ws = null;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client';
import { migrateStorageKeys } from './lib/storage-keys';
import { App } from './App';
import './index.css';
migrateStorageKeys();
createRoot(document.getElementById('root')!).render(<App />);
+59
View File
@@ -0,0 +1,59 @@
/// <reference lib="webworker" />
import { precacheAndRoute } from 'workbox-precaching';
declare const self: ServiceWorkerGlobalScope;
// Precache static assets (injected by vite-plugin-pwa at build time)
precacheAndRoute(self.__WB_MANIFEST);
// Push notification handler — server already filters by clientCount,
// so we always show the notification if one is received.
self.addEventListener('push', (event) => {
if (!event.data) return;
const payload = event.data.json();
event.waitUntil((async () => {
// Update badge (handle 0 explicitly to clear)
const badgeValue = payload.data?.badge;
if (typeof badgeValue === 'number') {
badgeValue > 0
? await navigator.setAppBadge?.(badgeValue)
: await navigator.clearAppBadge?.();
}
// Forward to app clients for real-time UI updates (e.g. pending badges)
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const c of allClients) {
c.postMessage({ type: 'PUSH_RECEIVED', sessionId: payload.data?.sessionId, badge: badgeValue });
}
// Silent push (no title) — badge-only update, don't show notification
if (!payload.title) return;
// Always show notification — server already filtered by clientCount
return self.registration.showNotification(payload.title, {
body: payload.body,
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
tag: payload.tag || payload.data?.sessionId || 'default',
data: payload.data,
});
})());
});
// Notification click — open app and navigate to session
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const sessionId = event.notification.data?.sessionId;
const url = sessionId ? `/?session=${sessionId}` : '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => {
for (const c of clients) {
if ('focus' in c) {
c.postMessage({ type: 'OPEN_SESSION', sessionId });
return (c as WindowClient).focus();
}
}
return self.clients.openWindow(url);
})
);
});
+13
View File
@@ -0,0 +1,13 @@
export interface AdapterConfig {
models: { value: string; label: string; contextWindow: number }[];
permissionModes: { value: string; label: string }[];
effortLevels: { value: string; label: string }[];
effortLabel: string;
}
export interface SavedInstruction {
id: string;
label: string;
instruction: string;
created_at: string;
}