diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 9759518..2d8ceaf 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -212,6 +212,37 @@ async function main() { }); registerWebSocket(app, sql, broker); + // v2.0.0: reverse proxy /api/coder/* to the boocoder container. Keeps the + // SPA's HTTP requests going through a single origin (avoids CORS). WS for + // the coder pane connects directly to boocoder:9502 from the browser (same + // Tailscale network — no CORS issue for WebSocket upgrade requests). + const BOOCODER_ORIGIN = process.env.BOOCODER_URL ?? 'http://boocoder:3000'; + app.all('/api/coder/*', async (req, reply) => { + const targetPath = req.url.replace('/api/coder', '/api'); + const targetUrl = `${BOOCODER_ORIGIN}${targetPath}`; + const headers: Record = {}; + if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string; + if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string; + + try { + const res = await fetch(targetUrl, { + method: req.method as string, + headers, + body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined, + }); + reply.code(res.status); + for (const [key, value] of res.headers) { + if (key === 'transfer-encoding') continue; + reply.header(key, value); + } + const body = await res.text(); + return reply.send(body); + } catch (err) { + app.log.error({ err, targetUrl }, 'coder proxy error'); + reply.code(502).send({ error: 'boocoder backend unavailable' }); + } + }); + const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist'); if (existsSync(webDist)) { await app.register(fastifyStatic, { diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index 5660f9e..5bd99f5 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -56,7 +56,7 @@ export interface Session { export type WorkspacePaneKind = | 'chat' | 'terminal' - | 'agent' + | 'coder' | 'empty' | 'settings' | 'markdown_artifact' diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index aa59393..ce491ab 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -326,7 +326,7 @@ export interface AskUserAnswerSet { export type WorkspacePaneKind = | 'chat' | 'terminal' - | 'agent' + | 'coder' | 'empty' | 'settings' | 'markdown_artifact' diff --git a/apps/web/src/components/ChatTabBar.tsx b/apps/web/src/components/ChatTabBar.tsx index 91111e9..28a7358 100644 --- a/apps/web/src/components/ChatTabBar.tsx +++ b/apps/web/src/components/ChatTabBar.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Bot, History, MessageSquare, Plus, Terminal, X } from 'lucide-react'; +import { Code, History, MessageSquare, Plus, Terminal, X } from 'lucide-react'; import type { Chat, WorkspacePane } from '@/api/types'; import { StatusDot } from '@/components/StatusDot'; import { @@ -26,7 +26,7 @@ interface Props { onCloseOthers: (chatId: string) => void; onCloseToRight: (chatId: string) => void; onCloseAll: () => void; - onAddPane: (kind: 'chat' | 'terminal' | 'agent') => void; + onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void; onShowHistory: () => void; onRename: (chatId: string, name: string) => Promise; onRemovePane?: () => void; @@ -188,8 +188,8 @@ export function ChatTabBar({ onAddPane('terminal')}> New terminal - onAddPane('agent')}> - New agent + onAddPane('coder')}> + New coder diff --git a/apps/web/src/components/MobileTabSwitcher.tsx b/apps/web/src/components/MobileTabSwitcher.tsx index 11cb840..4339c12 100644 --- a/apps/web/src/components/MobileTabSwitcher.tsx +++ b/apps/web/src/components/MobileTabSwitcher.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; import { - Bot, + Code, ChevronDown, Edit2, MessageSquare, @@ -43,7 +43,7 @@ const SWIPE_VISUAL_CAP = 120; function paneIcon(kind: WorkspacePane['kind']) { if (kind === 'terminal') return ; - if (kind === 'agent') return ; + if (kind === 'coder') return ; if (kind === 'settings') return ; return ; } @@ -64,7 +64,7 @@ function paneLabel(pane: WorkspacePane, chats: Chat[]): string { } if (pane.kind === 'chat') return 'Chat'; if (pane.kind === 'terminal') return 'Terminal'; - if (pane.kind === 'agent') return 'Agent'; + if (pane.kind === 'coder') return 'Coder'; if (pane.kind === 'settings') return 'Settings'; return 'Empty'; } diff --git a/apps/web/src/components/NewPaneMenu.tsx b/apps/web/src/components/NewPaneMenu.tsx index 6f71c8e..4d3cd10 100644 --- a/apps/web/src/components/NewPaneMenu.tsx +++ b/apps/web/src/components/NewPaneMenu.tsx @@ -1,4 +1,4 @@ -import { Bot, MessageSquare, Plus, Terminal } from 'lucide-react'; +import { Code, MessageSquare, Plus, Terminal } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -7,14 +7,13 @@ import { } from '@/components/ui/dropdown-menu'; interface Props { - onAddPane: (kind: 'chat' | 'terminal' | 'agent') => void; + onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void; disabled?: boolean; } // v1.8 row-2 right cluster: mirrors the desktop Workspace.tsx Split dropdown. -// Terminal and Agent items pass through to addSplitPane which already shows -// "coming soon" toasts; rendering them here matches the Batch 3 workspace -// model so the UI is forward-compatible with BooTerm/BooCoder. +// Terminal + Coder items pass through to addSplitPane which creates panes +// of the appropriate kind. export function NewPaneMenu({ onAddPane, disabled }: Props) { return ( @@ -35,8 +34,8 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) { onAddPane('terminal')}> New terminal - onAddPane('agent')}> - New agent + onAddPane('coder')}> + New coder diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index f3cda7b..77368c2 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { PanelRight, MessageSquare, Terminal, Bot, Clipboard, Plus, X } from 'lucide-react'; +import { PanelRight, MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react'; import type { Chat, Project, Session, WorkspacePane } from '@/api/types'; import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; import type { UseSessionChatsResult } from '@/hooks/useSessionChats'; @@ -8,6 +8,7 @@ import { terminalsRegistry } from '@/lib/events'; import { ChatPane } from '@/components/panes/ChatPane'; import { SettingsPane } from '@/components/panes/SettingsPane'; import { TerminalPane } from '@/components/panes/TerminalPane'; +import { CoderPane } from '@/components/panes/CoderPane'; import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane'; import { HtmlArtifactPane } from '@/components/HtmlArtifactPane'; import { ChatTabBar } from '@/components/ChatTabBar'; @@ -160,8 +161,8 @@ export function Workspace({ addSplitPane('terminal')}> Terminal - addSplitPane('agent')}> - Agent + addSplitPane('coder')}> + Coder @@ -264,8 +265,8 @@ export function Workspace({ addSplitPane('terminal')}> New terminal - addSplitPane('agent')}> - New agent + addSplitPane('coder')}> + New coder @@ -321,6 +322,8 @@ export function Workspace({ label={terminalLabels.get(pane.id) ?? 'Terminal'} active={idx === activePaneIdx} /> + ) : pane.kind === 'coder' ? ( + ) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? ( ; + 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 */} +
+
+