From 78455b7efcf07565cdf9fe964c893a2c20c0e1ba Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Mon, 25 May 2026 03:04:52 +0000 Subject: [PATCH] =?UTF-8?q?v2.0.0:=20BooCoder=20frontend=20=E2=80=94=20cha?= =?UTF-8?q?t=20pane=20+=20diff=20pane=20+=20session=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of v2.0. React + Vite SPA at apps/coder/web/ served by the coder Fastify server via @fastify/static with SPA fallback. Chat pane: message list via WS streaming (useSessionStream hook), input bar, POST /api/sessions/:id/messages on submit, markdown rendering via react-markdown + remark-gfm, inline tool-call display. Diff pane: fetches GET /api/sessions/:id/pending, shows pending changes with file path + operation badge (create/edit/delete), before/after diff for edits, Approve/Reject per change and Approve All/Reject All buttons. Layout: fixed two-pane split (chat 60%, diff 40%). Dark theme (bg-zinc-900). Desktop-first for v2.0.0. Session picker (Home page): lists projects and sessions from the shared DB. No CRUD — use BooChat's UI for that. Dockerfile updated: builds web app in builder stage, copies dist to runtime. index.ts registers fastifyStatic + SPA fallback route. Tailwind v4, React 18, TypeScript strict. ~20 new files, ~370KB built output. Functional developer tool UI, not polished consumer product — Phase 7 (v2.0.3) handles polish. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/coder/Dockerfile | 3 + apps/coder/src/index.ts | 29 ++ apps/coder/web/index.html | 12 + apps/coder/web/package.json | 29 ++ apps/coder/web/postcss.config.js | 5 + apps/coder/web/src/App.tsx | 13 + apps/coder/web/src/api/client.ts | 93 +++++ apps/coder/web/src/api/types.ts | 89 +++++ apps/coder/web/src/components/ChatPane.tsx | 131 +++++++ apps/coder/web/src/components/DiffPane.tsx | 352 ++++++++++++++++++ apps/coder/web/src/components/Layout.tsx | 62 +++ .../web/src/components/MessageBubble.tsx | 115 ++++++ apps/coder/web/src/globals.css | 22 ++ apps/coder/web/src/hooks/useSessionStream.ts | 230 ++++++++++++ apps/coder/web/src/main.tsx | 13 + apps/coder/web/src/pages/Home.tsx | 138 +++++++ apps/coder/web/src/pages/Session.tsx | 86 +++++ apps/coder/web/src/vite-env.d.ts | 1 + apps/coder/web/tsconfig.app.json | 27 ++ apps/coder/web/tsconfig.json | 13 + apps/coder/web/tsconfig.node.json | 14 + apps/coder/web/vite.config.d.ts | 2 + apps/coder/web/vite.config.js | 25 ++ apps/coder/web/vite.config.ts | 26 ++ pnpm-lock.yaml | 43 +++ pnpm-workspace.yaml | 1 + 26 files changed, 1574 insertions(+) create mode 100644 apps/coder/web/index.html create mode 100644 apps/coder/web/package.json create mode 100644 apps/coder/web/postcss.config.js create mode 100644 apps/coder/web/src/App.tsx create mode 100644 apps/coder/web/src/api/client.ts create mode 100644 apps/coder/web/src/api/types.ts create mode 100644 apps/coder/web/src/components/ChatPane.tsx create mode 100644 apps/coder/web/src/components/DiffPane.tsx create mode 100644 apps/coder/web/src/components/Layout.tsx create mode 100644 apps/coder/web/src/components/MessageBubble.tsx create mode 100644 apps/coder/web/src/globals.css create mode 100644 apps/coder/web/src/hooks/useSessionStream.ts create mode 100644 apps/coder/web/src/main.tsx create mode 100644 apps/coder/web/src/pages/Home.tsx create mode 100644 apps/coder/web/src/pages/Session.tsx create mode 100644 apps/coder/web/src/vite-env.d.ts create mode 100644 apps/coder/web/tsconfig.app.json create mode 100644 apps/coder/web/tsconfig.json create mode 100644 apps/coder/web/tsconfig.node.json create mode 100644 apps/coder/web/vite.config.d.ts create mode 100644 apps/coder/web/vite.config.js create mode 100644 apps/coder/web/vite.config.ts diff --git a/apps/coder/Dockerfile b/apps/coder/Dockerfile index 6facdab..8ff63fd 100644 --- a/apps/coder/Dockerfile +++ b/apps/coder/Dockerfile @@ -7,6 +7,7 @@ WORKDIR /build COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ COPY apps/server/package.json ./apps/server/ COPY apps/coder/package.json ./apps/coder/ +COPY apps/coder/web/package.json ./apps/coder/web/ RUN pnpm install --frozen-lockfile @@ -15,6 +16,7 @@ COPY apps/server ./apps/server RUN pnpm -C apps/server build COPY apps/coder ./apps/coder +RUN pnpm -C apps/coder/web build RUN pnpm -C apps/coder build RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder @@ -25,6 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git && WORKDIR /app COPY --from=builder /out/coder ./ +COPY --from=builder /build/apps/coder/web/dist ./web ENV NODE_ENV=production EXPOSE 3000 diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index f02b917..4de35df 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -1,5 +1,12 @@ +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync } from 'node:fs'; import Fastify from 'fastify'; import fastifyWebsocket from '@fastify/websocket'; +import fastifyStatic from '@fastify/static'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); import { loadConfig } from './config.js'; import { getSql, applySchema, pingDb, closeDb } from './db.js'; // v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the @@ -111,6 +118,28 @@ async function main() { registerPendingRoutes(app, sql); registerWebSocket(app, sql, broker); + // Serve static frontend (built web app). In production, the dist/ is + // copied to ../web relative to the dist/ directory at /app/web. In dev, + // check adjacent to the source. + const webRoot = resolve(__dirname, '../web'); + if (existsSync(webRoot)) { + await app.register(fastifyStatic, { + root: webRoot, + prefix: '/', + // Don't intercept /api routes — static only serves files that exist. + wildcard: false, + }); + // SPA fallback: serve index.html for non-API routes that don't match a file. + app.setNotFoundHandler(async (req, reply) => { + if (req.url.startsWith('/api')) { + reply.code(404); + return { error: 'not found' }; + } + return reply.sendFile('index.html'); + }); + app.log.info(`serving frontend from ${webRoot}`); + } + // Graceful shutdown const shutdown = async () => { app.log.info('shutting down'); diff --git a/apps/coder/web/index.html b/apps/coder/web/index.html new file mode 100644 index 0000000..79cab15 --- /dev/null +++ b/apps/coder/web/index.html @@ -0,0 +1,12 @@ + + + + + + BooCoder + + +
+ + + diff --git a/apps/coder/web/package.json b/apps/coder/web/package.json new file mode 100644 index 0000000..2eb5c7c --- /dev/null +++ b/apps/coder/web/package.json @@ -0,0 +1,29 @@ +{ + "name": "@boocode/coder-web", + "version": "2.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^1.16.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.26.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "tailwindcss": "^4.3.0", + "typescript": "^5.5.0", + "vite": "^5.3.4" + } +} diff --git a/apps/coder/web/postcss.config.js b/apps/coder/web/postcss.config.js new file mode 100644 index 0000000..a34a3d5 --- /dev/null +++ b/apps/coder/web/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/apps/coder/web/src/App.tsx b/apps/coder/web/src/App.tsx new file mode 100644 index 0000000..c5382a4 --- /dev/null +++ b/apps/coder/web/src/App.tsx @@ -0,0 +1,13 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { Home } from './pages/Home'; +import { Session } from './pages/Session'; + +export function App() { + return ( + + } /> + } /> + } /> + + ); +} diff --git a/apps/coder/web/src/api/client.ts b/apps/coder/web/src/api/client.ts new file mode 100644 index 0000000..77c407e --- /dev/null +++ b/apps/coder/web/src/api/client.ts @@ -0,0 +1,93 @@ +import type { Project, Session, Chat, Message, PendingChange } from './types'; + +export class ApiError extends Error { + constructor( + public status: number, + public body: unknown, + ) { + super( + typeof body === 'object' && body && 'error' in body + ? String((body as { error: unknown }).error) + : `HTTP ${status}`, + ); + } +} + +async function request(path: string, init: RequestInit = {}): Promise { + const res = await fetch(path, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init.headers ?? {}), + }, + }); + if (res.status === 204) return undefined as T; + const text = await res.text(); + const data = text ? JSON.parse(text) : undefined; + if (!res.ok) throw new ApiError(res.status, data); + return data as T; +} + +export const api = { + health: () => request<{ ok: boolean; db: boolean; tools: number }>('/api/health'), + + projects: { + list: (params?: { status?: 'open' | 'archived' }) => + request(`/api/projects${params?.status ? `?status=${params.status}` : ''}`), + }, + + sessions: { + listForProject: (projectId: string, status?: 'open' | 'archived') => + request( + `/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`, + ), + get: (id: string) => request(`/api/sessions/${id}`), + }, + + chats: { + listForSession: (sessionId: string) => + request(`/api/sessions/${sessionId}/chats`), + create: (sessionId: string, body?: { name?: string }) => + request(`/api/sessions/${sessionId}/chats`, { + method: 'POST', + body: JSON.stringify(body ?? {}), + }), + }, + + messages: { + send: (sessionId: string, chatId: string, content: string) => + request<{ user_message_id: string; assistant_message_id: string }>( + `/api/sessions/${sessionId}/messages`, + { + method: 'POST', + body: JSON.stringify({ content, chat_id: chatId }), + }, + ), + stop: (sessionId: string) => + request<{ cancelled: boolean }>(`/api/sessions/${sessionId}/stop`, { + method: 'POST', + }), + }, + + pending: { + list: (sessionId: string) => + request(`/api/sessions/${sessionId}/pending`), + applyAll: (sessionId: string) => + request<{ results: Array<{ id: string; success: boolean; error?: string }> }>( + `/api/sessions/${sessionId}/pending/apply`, + { method: 'POST' }, + ), + applyOne: (changeId: string) => + request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/apply`, { + method: 'POST', + }), + rejectOne: (changeId: string) => + request<{ ok: boolean }>(`/api/pending/${changeId}/reject`, { + method: 'POST', + }), + rewindOne: (changeId: string) => + request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/rewind`, { + method: 'POST', + }), + }, +}; diff --git a/apps/coder/web/src/api/types.ts b/apps/coder/web/src/api/types.ts new file mode 100644 index 0000000..fc9d114 --- /dev/null +++ b/apps/coder/web/src/api/types.ts @@ -0,0 +1,89 @@ +// Minimal types for the BooCoder frontend. +// Shared DB entities (same schema as BooChat). + +export interface Project { + id: string; + name: string; + path: string; + status: 'open' | 'archived'; + created_at: string; + updated_at: string; +} + +export interface Session { + id: string; + project_id: string; + name: string | null; + model: string | null; + status: 'open' | 'archived'; + created_at: string; + updated_at: string; +} + +export interface Chat { + id: string; + session_id: string; + name: string | null; + status: 'open' | 'archived'; + created_at: string; + updated_at: string; +} + +export interface ToolCall { + id: string; + name: string; + arguments: string; +} + +export interface ToolResult { + tool_call_id: string; + output: string; + truncated?: boolean; + error?: boolean; +} + +export interface Message { + id: string; + session_id: string; + chat_id: string; + role: 'user' | 'assistant' | 'tool' | 'system'; + content: string; + kind: string; + tool_calls: ToolCall[] | null; + tool_results: ToolResult | null; + status: 'streaming' | 'complete' | 'failed' | 'cancelled'; + tokens_used: number | null; + ctx_used: number | null; + ctx_max: number | null; + started_at: string | null; + finished_at: string | null; + created_at: string; + metadata: unknown; +} + +export interface PendingChange { + id: string; + session_id: string; + task_id: string | null; + file_path: string; + operation: 'create' | 'edit' | 'delete'; + old_string: string | null; + new_string: string | null; + content: string | null; + diff: string | null; + status: 'pending' | 'applied' | 'rejected' | 'reverted'; + created_at: string; + applied_at: string | null; +} + +// WebSocket frame types (subset of what the coder backend publishes) +export type WsFrame = + | { type: 'snapshot'; messages: Message[] } + | { type: 'message_started'; message_id: string; chat_id: string; role: Message['role'] } + | { type: 'delta'; message_id: string; chat_id: string; content: string } + | { type: 'tool_call'; message_id: string; chat_id: string; tool_call: ToolCall } + | { type: 'tool_result'; tool_message_id: string; chat_id: string; tool_call_id: string; output: string; truncated?: boolean; error?: boolean } + | { type: 'message_complete'; message_id: string; chat_id: string; tokens_used?: number; ctx_used?: number; ctx_max?: number; started_at?: string; finished_at?: string; metadata?: unknown } + | { type: 'error'; message_id?: string; error: string; reason?: string } + | { type: 'pending_change_added'; change: PendingChange } + | { type: 'pending_change_updated'; change: PendingChange }; diff --git a/apps/coder/web/src/components/ChatPane.tsx b/apps/coder/web/src/components/ChatPane.tsx new file mode 100644 index 0000000..c56fdda --- /dev/null +++ b/apps/coder/web/src/components/ChatPane.tsx @@ -0,0 +1,131 @@ +import { useState, useRef, useEffect } from 'react'; +import { Send, Square } from 'lucide-react'; +import type { Message } from '@/api/types'; +import { api } from '@/api/client'; +import { MessageBubble } from './MessageBubble'; + +interface Props { + sessionId: string; + chatId: string; + messages: Message[]; + isStreaming: boolean; + connected: boolean; +} + +export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }: Props) { + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + // Auto-scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Auto-resize textarea + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 200) + 'px'; + }, [input]); + + const handleSend = async () => { + const content = input.trim(); + if (!content || sending || isStreaming) return; + + setInput(''); + setSending(true); + try { + await api.messages.send(sessionId, chatId, content); + } catch (err) { + console.error('send failed:', err); + // Restore input on failure + setInput(content); + } finally { + setSending(false); + } + }; + + const handleStop = async () => { + try { + await api.messages.stop(sessionId); + } catch (err) { + console.error('stop failed:', err); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + // Filter out system messages for display (sentinels) + const visibleMessages = messages.filter((m) => m.role !== 'system'); + + return ( +
+ {/* Connection indicator */} +
+
+ {connected ? 'Connected' : 'Disconnected'} + {isStreaming && ( + Generating... + )} +
+ + {/* Messages list */} +
+ {visibleMessages.length === 0 && ( +
+

BooCoder

+

Send a message to start coding.

+
+ )} + {visibleMessages.map((msg) => ( + + ))} +
+
+ + {/* Input area */} +
+
+