// BooCoder pane — chat + diff inside BooChat's multi-pane workspace. // // REST: /api/coder/* proxied by BooChat to host boocoder.service (:9502). // WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502). import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Code, Check, X, RefreshCw } from 'lucide-react'; import { AgentComposerBar } from '@/components/AgentComposerBar'; import { PermissionCard } from '@/components/PermissionCard'; import { ChatInput } from '@/components/ChatInput'; import { api } from '@/api/client'; import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types'; import { useSkills } from '@/hooks/useSkills'; import { toast } from 'sonner'; import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command'; import { mergeWireToolCall } from '@/lib/coder-tools'; import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList'; import { cn } from '@/lib/utils'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface CoderMessage { id: string; role: 'user' | 'assistant' | 'system'; content: string; status?: 'streaming' | 'complete' | 'failed'; reasoning_text?: string; tool_calls?: Array<{ id: string; function: { name: string; arguments: string }; }>; ctx_used?: number | null; ctx_max?: number | null; } interface CoderToolMessage { id: string; role: 'tool'; tool_results: { tool_call_id: string; output: unknown; truncated?: boolean; error?: string; }; } type CoderTimelineMessage = CoderMessage | CoderToolMessage; interface PendingChange { id: string; file_path: string; operation: 'create' | 'modify' | 'delete'; diff?: string; new_content?: string; status: 'pending' | 'approved' | 'rejected'; } interface Props { sessionId: string; paneId: string; chatId?: string; chatPending?: boolean; projectPath?: string; onConnectedChange?: (connected: boolean) => void; onAgentLabelChange?: (label: string) => void; } interface WsHandlers { onPermissionRequested?: (prompt: PermissionPrompt) => void; onPermissionResolved?: (taskId: string) => void; onAssistantComplete?: () => void; onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void; onConnectedChange?: (connected: boolean) => void; } type RawCoderMessage = { id: string; role: string; chat_id?: string; content?: string | null; status?: string | null; reasoning_text?: string; reasoning_parts?: Array<{ text?: string }> | null; tool_results?: { tool_call_id: string; output: unknown; truncated?: boolean; error?: string; } | null; tool_calls?: Array< | { id: string; name: string; args?: Record } | { id: string; function: { name: string; arguments: string } } > | null; ctx_used?: number | null; ctx_max?: number | null; }; function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null { if (raw.role === 'tool') { if (!raw.tool_results?.tool_call_id) return null; return { id: raw.id, role: 'tool', tool_results: raw.tool_results, }; } if (raw.role !== 'user' && raw.role !== 'assistant' && raw.role !== 'system') return null; const tool_calls = raw.tool_calls?.map((tc) => { if ('function' in tc) { return { id: tc.id, function: tc.function }; } return { id: tc.id, function: { name: tc.name, arguments: JSON.stringify(tc.args ?? {}), }, }; }); const reasoning_text = raw.reasoning_text ?? raw.reasoning_parts?.map((p) => p.text ?? '').join('') ?? ''; return { id: raw.id, role: raw.role as CoderMessage['role'], content: raw.content ?? '', status: (raw.status ?? 'complete') as CoderMessage['status'], ...(reasoning_text ? { reasoning_text } : {}), ...(tool_calls?.length ? { tool_calls } : {}), ctx_used: raw.ctx_used ?? null, ctx_max: raw.ctx_max ?? null, }; } function useCoderMessages(sessionId: string, chatId: string | undefined, handlers: WsHandlers) { const [messages, setMessages] = useState([]); const [connected, setConnected] = useState(false); const wsRef = useRef(null); const handlersRef = useRef(handlers); handlersRef.current = handlers; const chatIdRef = useRef(chatId); chatIdRef.current = chatId; const loadMessages = useCallback(() => { if (!chatId) { setMessages([]); return Promise.resolve(); } return api.coder .listMessages(sessionId, chatId) .then((rows) => setMessages( rows .map(mapCoderTimelineRow) .filter((m): m is CoderTimelineMessage => m !== null), ), ) .catch(() => {/* boocoder may be down */}); }, [sessionId, chatId]); useEffect(() => { void loadMessages(); }, [loadMessages]); 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); const scopedChatId = chatIdRef.current; if ( scopedChatId && frame.chat_id && frame.chat_id !== scopedChatId && frame.type !== 'snapshot' ) { return; } if (frame.type === 'snapshot' && Array.isArray(frame.messages)) { const rawMessages = (frame.messages as RawCoderMessage[]).filter( (m) => !scopedChatId || m.chat_id === scopedChatId, ); setMessages( rawMessages .map(mapCoderTimelineRow) .filter((m): m is CoderTimelineMessage => m !== null), ); } else if (frame.type === 'message_started') { setMessages((prev) => { if (prev.some((m) => m.id === frame.message_id)) return prev; const role = frame.role ?? 'assistant'; const tempIdx = role === 'user' ? prev.findIndex((m) => m.id.startsWith('temp-') && m.role === 'user') : -1; if (tempIdx >= 0) { return prev.map((m, i) => i === tempIdx ? { ...m, id: frame.message_id, status: 'streaming' } : m, ); } return [ ...prev, { id: frame.message_id, role, content: '', status: 'streaming' }, ]; }); } else if (frame.type === 'delta') { setMessages((prev) => prev.map((m) => { if (m.id !== frame.message_id || m.role === 'tool') return m; const chunk = frame.content ?? ''; if (m.role === 'user') { return { ...m, content: chunk || m.content }; } return { ...m, content: m.content + chunk }; }), ); } else if (frame.type === 'message_complete') { setMessages((prev) => { const completed = prev.find( (m): m is CoderMessage => m.id === frame.message_id && m.role === 'assistant', ); const next = prev.map((m) => m.id === frame.message_id && m.role !== 'tool' ? { ...m, status: 'complete' as const, ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null, ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null, } : m, ); if (completed) { queueMicrotask(() => handlersRef.current.onAssistantComplete?.()); } return next; }); } else if (frame.type === 'tool_call') { const tc = frame.tool_call as { id: string; name: string; args?: Record } | undefined; if (tc?.id) { setMessages((prev) => prev.map((m) => m.role !== 'assistant' || m.id !== frame.message_id ? m : { ...m, tool_calls: mergeWireToolCall(m.tool_calls, { ...tc, args: tc.args ?? {} }) }, ), ); } } else if (frame.type === 'tool_result') { setMessages((prev) => { const exists = prev.some((m) => m.id === frame.tool_message_id); if (exists) { return prev.map((m) => m.role === 'tool' && m.id === frame.tool_message_id ? { ...m, tool_results: { tool_call_id: frame.tool_call_id, output: frame.output, truncated: frame.truncated, ...(frame.error ? { error: frame.error } : {}), }, } : m, ); } return [ ...prev, { id: frame.tool_message_id, role: 'tool' as const, tool_results: { tool_call_id: frame.tool_call_id, output: frame.output, truncated: frame.truncated, ...(frame.error ? { error: frame.error } : {}), }, }, ]; }); } else if (frame.type === 'reasoning_delta') { setMessages((prev) => prev.map((m) => m.id === frame.message_id && m.role === 'assistant' ? { ...m, reasoning_text: (m.reasoning_text ?? '') + (frame.content ?? '') } : m, ), ); } else if (frame.type === 'permission_requested') { handlersRef.current.onPermissionRequested?.({ taskId: frame.task_id, kind: frame.kind, toolTitle: frame.tool_title, ...(frame.input ? { input: frame.input as Record } : {}), options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({ optionId: o.option_id, label: o.label, })), }); } else if (frame.type === 'permission_resolved') { handlersRef.current.onPermissionResolved?.(frame.task_id); } else if (frame.type === 'agent_commands') { handlersRef.current.onAgentCommands?.( frame.task_id, (frame.commands ?? []).map((c: { name: string; description?: string }) => ({ name: c.name, description: c.description, })), ); } } catch { // ignore unparseable frames } }; return () => { ws.close(); wsRef.current = null; }; }, [sessionId]); useEffect(() => { handlersRef.current.onConnectedChange?.(connected); }, [connected]); return { messages, setMessages, connected, loadMessages }; } 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/pending/${changeId}/apply`, { 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/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 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, paneId, chatId, chatPending = false, projectPath, onConnectedChange, onAgentLabelChange, }: Props) { const [agentConfig, setAgentConfig] = useState({ provider: 'boocode', model: '', modeId: null, thinkingOptionId: null, }); useEffect(() => { const parts = [agentConfig.provider || 'boocode']; if (agentConfig.model) parts.push(agentConfig.model); onAgentLabelChange?.(parts.join(' · ')); }, [agentConfig.provider, agentConfig.model, onAgentLabelChange]); const [activeTaskId, setActiveTaskId] = useState(null); const [permissionPrompt, setPermissionPrompt] = useState(null); const [permissionBusy, setPermissionBusy] = useState(false); const [providerCommands, setProviderCommands] = useState([]); const [liveTaskCommands, setLiveTaskCommands] = useState([]); const { skills } = useSkills(); const [slashState, setSlashState] = useState<{ query: string } | null>(null); const displayedCommands = useMemo(() => { const base = agentConfig.provider === 'boocode' ? skills.map((s) => ({ name: s.name, description: s.description })) : providerCommands; return mergeCommandsByName(base, liveTaskCommands); }, [agentConfig.provider, skills, providerCommands, liveTaskCommands]); const skillsByName = useMemo(() => new Set(skills.map((s) => s.name)), [skills]); const commandsByName = useMemo( () => new Set(displayedCommands.map((c) => c.name)), [displayedCommands], ); const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, { onConnectedChange, onPermissionRequested: (prompt) => { setActiveTaskId(prompt.taskId); setPermissionPrompt(prompt); }, onPermissionResolved: (taskId) => { if (activeTaskId === taskId || permissionPrompt?.taskId === taskId) { setPermissionPrompt(null); } }, onAssistantComplete: () => { setActiveTaskId(null); setPermissionPrompt(null); setLiveTaskCommands([]); }, onAgentCommands: (_taskId, commands) => { setLiveTaskCommands(commands); }, }); const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const [queue, setQueue] = useState([]); const queueProcessing = useRef(false); const inputRef = useRef(null); // Refresh pending changes when a message_complete arrives useEffect(() => { const lastAssistant = [...messages].reverse().find( (m): m is CoderMessage => m.role === 'assistant', ); if (lastAssistant?.status === 'complete') { refresh(); } }, [messages, refresh]); // Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth) useEffect(() => { if (!activeTaskId || connected) return; const interval = setInterval(() => { if (!permissionPrompt) { void api.coder .getTaskPermission(activeTaskId) .then((prompt) => { setPermissionPrompt({ taskId: prompt.taskId, toolTitle: prompt.toolTitle, options: prompt.options, }); }) .catch(() => {/* no pending permission */}); } void api.coder .getTaskCommands(activeTaskId) .then((res) => setLiveTaskCommands(res.commands)) .catch(() => {/* not cached yet */}); void api.coder .getTask(activeTaskId) .then((task) => { if (task.state === 'running' || task.state === 'pending' || task.state === 'blocked') { return; } setActiveTaskId(null); setPermissionPrompt(null); setLiveTaskCommands([]); void loadMessages(); }) .catch(() => {/* task gone */}); }, 2000); return () => clearInterval(interval); }, [activeTaskId, connected, permissionPrompt, loadMessages]); const handleProviderCommandsChange = useCallback((commands: AgentCommand[]) => { setProviderCommands(commands); }, []); const handlePermissionRespond = useCallback(async (optionId: string | null, updatedInput?: Record) => { if (!permissionPrompt) return; setPermissionBusy(true); try { await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId, updatedInput); setPermissionPrompt(null); } finally { setPermissionBusy(false); } }, [permissionPrompt]); const handleSend = useCallback(async () => { const text = input.trim(); if (!text || sending || !chatId) return; if (text.startsWith('/')) { const parsed = parseSlashInput(text); if (parsed) { const { cmdName, args } = parsed; if (agentConfig.provider === 'boocode' && skillsByName.has(cmdName)) { setInput(''); setSlashState(null); setSending(true); setPermissionPrompt(null); setLiveTaskCommands([]); try { await api.coder.skillInvoke( sessionId, paneId, cmdName, args.length > 0 ? args : null, ); } catch (err) { toast.error(err instanceof Error ? err.message : 'skill invocation failed'); } finally { setSending(false); } return; } if (!commandsByName.has(cmdName)) { // Unknown slash — fall through and send as literal text. } } } setInput(''); setSlashState(null); setSending(true); setPermissionPrompt(null); setLiveTaskCommands([]); const tempId = `temp-${Date.now()}`; setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]); try { const data = await api.coder.sendMessage(sessionId, { content: text, pane_id: paneId, chat_id: chatId, provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined, model: agentConfig.model || undefined, mode_id: agentConfig.modeId ?? undefined, thinking_option_id: agentConfig.thinkingOptionId ?? undefined, }); if (data.user_message_id) { setMessages((prev) => prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m)) ); } if (data.task_id) { setActiveTaskId(data.task_id); } else { setActiveTaskId(null); } } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to send'); } finally { setSending(false); } }, [ input, sending, sessionId, paneId, chatId, agentConfig, skillsByName, commandsByName, setMessages, ]); const sendOneMessage = useCallback(async (text: string) => { if (!chatId) return; setSending(true); setPermissionPrompt(null); setLiveTaskCommands([]); const tempId = `temp-${Date.now()}`; setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]); try { const data = await api.coder.sendMessage(sessionId, { content: text, pane_id: paneId, chat_id: chatId, provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined, model: agentConfig.model || undefined, mode_id: agentConfig.modeId ?? undefined, thinking_option_id: agentConfig.thinkingOptionId ?? undefined, }); if (data.user_message_id) { setMessages((prev) => prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m)) ); } if (data.task_id) { setActiveTaskId(data.task_id); } else { setActiveTaskId(null); } } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to send'); } finally { setSending(false); } }, [sessionId, paneId, chatId, agentConfig, setMessages]); // Drain queue when not busy useEffect(() => { if (sending || queue.length === 0 || queueProcessing.current) return; queueProcessing.current = true; const next = queue[0]!; setQueue((prev) => prev.slice(1)); sendOneMessage(next).finally(() => { queueProcessing.current = false; }); }, [sending, queue, sendOneMessage]); const handleChatInputSend = useCallback(async (content: string) => { const text = content.trim(); if (!text || !chatId) return; if (sending) { setQueue((prev) => [...prev, text]); return; } await sendOneMessage(text); }, [sending, chatId, sendOneMessage]); const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => { if (!chatId) return; if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) { setSending(true); setPermissionPrompt(null); setLiveTaskCommands([]); try { await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null); } catch (err) { toast.error(err instanceof Error ? err.message : 'skill invocation failed'); } finally { setSending(false); } } }, [chatId, sessionId, paneId, agentConfig.provider, skillsByName]); return (
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
{messages.length === 0 ? (

{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}

) : ( { await sendOneMessage(content); }, }} footer={ activeTaskId && !permissionPrompt && sending === false ? (

Agent running…

) : undefined } /> )}
{permissionPrompt && ( void handlePermissionRespond(id, input)} busy={permissionBusy} /> )} {/* Diff panel — only shows when there are pending changes */} {changes.filter((c) => c.status === 'pending').length > 0 && (
)} {/* Composer + input */}
); }