import { useState, useEffect, useRef, useCallback } from 'react'; import _rwsDefault, { ReadyState } from 'react-use-websocket'; // Rolldown/Vite 8 CJS interop: probe all possible locations for the hook const useWebSocket = (() => { if (typeof _rwsDefault === 'function') return _rwsDefault; const a = _rwsDefault as any; if (typeof a?.default === 'function') return a.default; if (typeof a?.useWebSocket === 'function') return a.useWebSocket; console.error('[rws] Could not resolve useWebSocket, got:', typeof _rwsDefault, Object.keys(a || {})); return _rwsDefault; })() as typeof _rwsDefault; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import type { Session, DbMessage, WsOutbound } from '../types'; import ToolUseBlock from './ToolUseBlock'; const WS_URL = '/ws'; // ---- display message types (client-side only) ---- interface DisplayMessage { id: string; role: 'user' | 'assistant' | 'tool_use' | 'tool_result'; content: string; toolName?: string; toolInput?: Record; timestamp: number; } function getReadyStateLabel(state: ReadyState): { label: string; color: string } { switch (state) { case ReadyState.CONNECTING: return { label: 'Connecting', color: 'text-yellow-400' }; case ReadyState.OPEN: return { label: 'Connected', color: 'text-green-400' }; case ReadyState.CLOSING: return { label: 'Closing', color: 'text-orange-400' }; case ReadyState.CLOSED: return { label: 'Disconnected', color: 'text-red-400' }; default: return { label: 'Unknown', color: 'text-gray-400' }; } } function dbMessageToDisplay(m: DbMessage): DisplayMessage { let toolInput: Record | undefined; if (m.tool_input) { try { toolInput = JSON.parse(m.tool_input); } catch { toolInput = { raw: m.tool_input }; } } return { id: String(m.id), role: m.role, content: m.content ?? '', toolName: m.tool_name ?? undefined, toolInput, timestamp: new Date(m.created_at).getTime() }; } interface MessageBubbleProps { message: DisplayMessage; } function MessageBubble({ message }: MessageBubbleProps) { const { role, content, toolName, toolInput, timestamp } = message; if (role === 'tool_use' && toolName) { return (
{new Date(timestamp).toLocaleTimeString()}
); } if (role === 'tool_result') { return (
result: {content.slice(0, 300)}{content.length > 300 ? '...' : ''}
); } const isUser = role === 'user'; return (
{isUser ? ( {content} ) : (
{content}
)}
{new Date(timestamp).toLocaleTimeString()}
); } interface SkillItem { id: string; name: string; command: string; description: string; } export default function ChatWindow() { const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isRunning, setIsRunning] = useState(false); const [hasResponse, setHasResponse] = useState(false); const [selectedCli, setSelectedCli] = useState('claude'); const [availableClis, setAvailableClis] = useState(['claude']); // Slash command autocomplete const [allSkills, setAllSkills] = useState([]); const [showAutocomplete, setShowAutocomplete] = useState(false); const [autocompleteItems, setAutocompleteItems] = useState([]); const [autocompleteIndex, setAutocompleteIndex] = useState(0); const messagesEndRef = useRef(null); const inputRef = useRef(null); const activeSessionRef = useRef(null); const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(WS_URL, { shouldReconnect: () => true, reconnectInterval: 2000, reconnectAttempts: 20, onOpen: () => { // Re-subscribe to active session after reconnect if (activeSessionRef.current) { sendJsonMessage({ type: 'subscribe', sessionId: activeSessionRef.current }); } } }); const { label: wsLabel, color: wsColor } = getReadyStateLabel(readyState); // Scroll to bottom on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, isRunning]); // Load available CLIs + default setting + skills on mount useEffect(() => { fetch('/api/cli-available') .then((r) => r.json()) .then((data) => { if (Array.isArray(data)) setAvailableClis(data); }) .catch(() => {}); fetch('/api/settings') .then((r) => r.json()) .then((data: Record) => { if (data.default_cli) setSelectedCli(data.default_cli); }) .catch(() => {}); fetch('/api/skills') .then((r) => r.json()) .then((data: SkillItem[]) => { if (Array.isArray(data)) setAllSkills(data); }) .catch(() => {}); }, []); // Load sessions on mount useEffect(() => { fetch('/api/sessions') .then((r) => r.json()) .then((data: Session[]) => { if (Array.isArray(data)) { setSessions(data); if (data.length > 0) { setActiveSessionId(data[0].id); } } }) .catch(console.error); }, []); // Subscribe to session when it changes useEffect(() => { if (!activeSessionId || readyState !== ReadyState.OPEN) return; activeSessionRef.current = activeSessionId; sendJsonMessage({ type: 'subscribe', sessionId: activeSessionId }); }, [activeSessionId, readyState, sendJsonMessage]); // Handle WS messages useEffect(() => { if (!lastJsonMessage) return; const msg = lastJsonMessage as WsOutbound; switch (msg.type) { case 'connected': // Re-subscribe if we have a session if (activeSessionRef.current) { sendJsonMessage({ type: 'subscribe', sessionId: activeSessionRef.current }); } break; case 'history': { const displayed = msg.messages.map(dbMessageToDisplay); setMessages(displayed); setIsRunning(msg.running); break; } case 'user_message': // Already added optimistically โ€” skip if duplicate break; case 'assistant_message': { const assistantMsg: DisplayMessage = { id: `assistant-${Date.now()}-${Math.random()}`, role: 'assistant', content: msg.content, timestamp: Date.now() }; setMessages((prev) => { // Merge with last assistant message if streaming continuation // (server sends individual assistant messages, not streaming chunks) return [...prev, assistantMsg]; }); setHasResponse(true); break; } case 'tool_use': { const toolMsg: DisplayMessage = { id: `tool-${Date.now()}-${Math.random()}`, role: 'tool_use', content: '', toolName: msg.toolName, toolInput: msg.toolInput, timestamp: Date.now() }; setMessages((prev) => [...prev, toolMsg]); break; } case 'tool_result': { const resultMsg: DisplayMessage = { id: `result-${Date.now()}-${Math.random()}`, role: 'tool_result', content: msg.content, timestamp: Date.now() }; setMessages((prev) => [...prev, resultMsg]); break; } case 'result': setIsRunning(false); break; case 'interrupted': setIsRunning(false); break; case 'error': console.error('[WS error]:', msg.error); setIsRunning(false); break; } }, [lastJsonMessage, sendJsonMessage]); const createSession = useCallback(async () => { try { const res = await fetch('/api/sessions', { method: 'POST' }); const session: Session = await res.json(); setSessions((prev) => [session, ...prev]); setActiveSessionId(session.id); setMessages([]); } catch (err) { console.error('Failed to create session:', err); } }, []); const handleSessionChange = useCallback( (sessionId: string) => { setActiveSessionId(sessionId); setMessages([]); setIsRunning(false); if (readyState === ReadyState.OPEN) { sendJsonMessage({ type: 'subscribe', sessionId }); } }, [readyState, sendJsonMessage] ); const sendMessage = useCallback(() => { const text = input.trim(); if (!text || readyState !== ReadyState.OPEN || isRunning || !activeSessionId) return; // Optimistic user message const userMsg: DisplayMessage = { id: `local-${Date.now()}`, role: 'user', content: text, timestamp: Date.now() }; setMessages((prev) => [...prev, userMsg]); setInput(''); setIsRunning(true); setHasResponse(false); sendJsonMessage({ type: 'chat', sessionId: activeSessionId, content: text, cli: selectedCli }); }, [input, readyState, isRunning, activeSessionId, selectedCli, sendJsonMessage]); const interruptSession = useCallback(() => { if (!activeSessionId) return; sendJsonMessage({ type: 'interrupt', sessionId: activeSessionId }); setIsRunning(false); }, [activeSessionId, sendJsonMessage]); const selectAutocomplete = (skill: SkillItem) => { setInput(`/${skill.id} `); setShowAutocomplete(false); inputRef.current?.focus(); }; const handleKeyDown = (e: React.KeyboardEvent) => { // Autocomplete navigation if (showAutocomplete && autocompleteItems.length > 0) { if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) { e.preventDefault(); selectAutocomplete(autocompleteItems[autocompleteIndex]); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setAutocompleteIndex(i => Math.min(i + 1, autocompleteItems.length - 1)); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setAutocompleteIndex(i => Math.max(i - 1, 0)); return; } if (e.key === 'Escape') { setShowAutocomplete(false); return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }; return (
{/* Top bar */}
{isRunning && ( )} {/* WS status */}
{wsLabel}
{/* Messages */}
{messages.length === 0 && !isRunning && (
๐Ÿค–

Start a conversation with Claude Agent

{sessions.length === 0 && ( )}
)} {messages.map((msg) => ( ))} {/* Running indicator โ€” big dots before first response, subtle after */} {isRunning && !hasResponse && (
)}
{/* Input */}
{/* Slash command autocomplete dropdown */} {showAutocomplete && autocompleteItems.length > 0 && (
{autocompleteItems.map((skill, i) => ( ))}
)}