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:
+296
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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' }),
|
||||
};
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 />);
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user