// v2.0.0: BooCoder pane — renders the BooCoder chat + diff interface inside // BooChat's multi-pane workspace. // // Architecture: // - REST calls go through /api/coder/* which BooChat's server proxies to // the boocoder container at http://boocoder:3000/api/* // - WS connects directly to the boocoder container at :9502 (same Tailscale // network, no CORS for WebSocket). In dev, the Vite proxy handles it. import { useCallback, useEffect, useRef, useState } from 'react'; import { Code, Send, Check, X, RefreshCw } from 'lucide-react'; import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { cn } from '@/lib/utils'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface CoderMessage { id: string; role: 'user' | 'assistant' | 'system'; content: string; status?: 'streaming' | 'complete' | 'failed'; tool_calls?: Array<{ id: string; function: { name: string; arguments: string }; }>; tool_results?: { tool_call_id: string; content: string; }; } interface PendingChange { id: string; file_path: string; operation: 'create' | 'modify' | 'delete'; diff?: string; new_content?: string; status: 'pending' | 'approved' | 'rejected'; } interface Props { sessionId: string; } // --------------------------------------------------------------------------- // Hooks // --------------------------------------------------------------------------- function useCoderMessages(sessionId: string) { const [messages, setMessages] = useState([]); const [connected, setConnected] = useState(false); const wsRef = useRef(null); useEffect(() => { // Fetch existing messages on mount fetch(`/api/coder/sessions/${sessionId}/messages`) .then((res) => res.ok ? res.json() : []) .then((data: CoderMessage[]) => setMessages(data)) .catch(() => {/* noop — coder backend may not be running */}); }, [sessionId]); useEffect(() => { // WS connects to the coder backend. In production, this goes through the // same host (BooChat serves the SPA and proxies). In dev, Vite proxy // handles /api/coder/ws/* -> boocoder:9502. const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${window.location.host}/api/coder/ws/sessions/${sessionId}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => setConnected(true); ws.onclose = () => setConnected(false); ws.onmessage = (ev) => { try { const frame = JSON.parse(ev.data as string); if (frame.type === 'message_started') { setMessages((prev) => [ ...prev, { id: frame.message_id, role: frame.role ?? 'assistant', content: '', status: 'streaming' }, ]); } else if (frame.type === 'delta') { setMessages((prev) => prev.map((m) => m.id === frame.message_id ? { ...m, content: m.content + (frame.content ?? '') } : m ) ); } else if (frame.type === 'message_complete') { setMessages((prev) => prev.map((m) => m.id === frame.message_id ? { ...m, status: 'complete' } : m ) ); } else if (frame.type === 'tool_call') { setMessages((prev) => prev.map((m) => m.id === frame.message_id ? { ...m, tool_calls: [ ...(m.tool_calls ?? []), { id: frame.tool_call_id, function: { name: frame.name, arguments: frame.arguments ?? '' } }, ], } : m ) ); } } catch { // ignore unparseable frames } }; return () => { ws.close(); wsRef.current = null; }; }, [sessionId]); return { messages, setMessages, connected }; } function usePendingChanges(sessionId: string) { const [changes, setChanges] = useState([]); const [loading, setLoading] = useState(false); const refresh = useCallback(() => { setLoading(true); fetch(`/api/coder/sessions/${sessionId}/pending`) .then((res) => res.ok ? res.json() : []) .then((data: PendingChange[]) => setChanges(data)) .catch(() => {/* noop */}) .finally(() => setLoading(false)); }, [sessionId]); useEffect(() => { refresh(); }, [refresh]); const approve = useCallback(async (changeId: string) => { const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/approve`, { method: 'POST', }); if (res.ok) { setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'approved' } : c)); } }, [sessionId]); const reject = useCallback(async (changeId: string) => { const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/reject`, { method: 'POST', }); if (res.ok) { setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'rejected' } : c)); } }, [sessionId]); return { changes, loading, refresh, approve, reject }; } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function CoderMessageBubble({ message }: { message: CoderMessage }) { const isUser = message.role === 'user'; return (
{isUser ? (

{message.content}

) : (
)} {message.tool_calls && message.tool_calls.length > 0 && (
{message.tool_calls.map((tc) => (
{tc.function.name} {tc.function.arguments && ( ({tc.function.arguments.slice(0, 80)} {tc.function.arguments.length > 80 ? '...' : ''}) )}
))}
)} {message.status === 'streaming' && ( )}
); } function DiffPanel({ changes, loading, onRefresh, onApprove, onReject, }: { changes: PendingChange[]; loading: boolean; onRefresh: () => void; onApprove: (id: string) => void; onReject: (id: string) => void; }) { const pending = changes.filter((c) => c.status === 'pending'); return (
Pending Changes {pending.length > 0 && `(${pending.length})`}
{pending.length === 0 ? (
No pending changes
) : (
{pending.map((change) => (
{change.file_path}
{change.diff && (
                    {change.diff}
                  
)}
))}
)}
); } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export function CoderPane({ sessionId }: Props) { const { messages, setMessages, connected } = useCoderMessages(sessionId); const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); // Auto-scroll on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Refresh pending changes when a message_complete arrives useEffect(() => { const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === 'assistant' && lastMsg.status === 'complete') { refresh(); } }, [messages, refresh]); const handleSend = useCallback(async () => { const text = input.trim(); if (!text || sending) return; setInput(''); setSending(true); // Optimistic user message const tempId = `temp-${Date.now()}`; setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]); try { const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: text }), }); if (res.ok) { const data = await res.json(); // Replace temp message with real one if server returned it if (data.user_message_id) { setMessages((prev) => prev.map((m) => m.id === tempId ? { ...m, id: data.user_message_id } : m) ); } } } catch { // The WS will bring the real messages; optimistic is good enough } finally { setSending(false); } }, [input, sending, sessionId, setMessages]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void handleSend(); } }, [handleSend] ); return (
{/* Header */}
BooCoder
{/* Chat area */}
{messages.length === 0 ? (

Send a message to start coding

) : (
{messages.map((msg) => ( ))}
)}
{/* Diff panel — only shows when there are pending changes */} {changes.filter((c) => c.status === 'pending').length > 0 && (
)} {/* Input */}