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 */}
+
+
+ );
+}
diff --git a/apps/coder/web/src/components/DiffPane.tsx b/apps/coder/web/src/components/DiffPane.tsx
new file mode 100644
index 0000000..a2bfac8
--- /dev/null
+++ b/apps/coder/web/src/components/DiffPane.tsx
@@ -0,0 +1,352 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Check, X, RotateCcw, FileText, FilePlus, Trash2, RefreshCw } from 'lucide-react';
+import type { PendingChange } from '@/api/types';
+import { api } from '@/api/client';
+
+interface Props {
+ sessionId: string;
+ onPendingChange: (cb: (change: PendingChange) => void) => () => void;
+}
+
+export function DiffPane({ sessionId, onPendingChange }: Props) {
+ const [changes, setChanges] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [expandedId, setExpandedId] = useState(null);
+
+ const fetchPending = useCallback(async () => {
+ try {
+ const result = await api.pending.list(sessionId);
+ setChanges(result);
+ } catch (err) {
+ console.error('fetch pending failed:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [sessionId]);
+
+ // Initial load
+ useEffect(() => {
+ fetchPending();
+ }, [fetchPending]);
+
+ // Listen for WS pending change events
+ useEffect(() => {
+ const unsub = onPendingChange((change) => {
+ setChanges((prev) => {
+ const idx = prev.findIndex((c) => c.id === change.id);
+ if (idx >= 0) {
+ const next = [...prev];
+ next[idx] = change;
+ return next;
+ }
+ return [...prev, change];
+ });
+ });
+ return unsub;
+ }, [onPendingChange]);
+
+ const pendingChanges = changes.filter((c) => c.status === 'pending');
+ const resolvedChanges = changes.filter((c) => c.status !== 'pending');
+
+ const handleApplyOne = async (id: string) => {
+ try {
+ await api.pending.applyOne(id);
+ setChanges((prev) =>
+ prev.map((c) => (c.id === id ? { ...c, status: 'applied' as const } : c)),
+ );
+ } catch (err) {
+ console.error('apply failed:', err);
+ }
+ };
+
+ const handleRejectOne = async (id: string) => {
+ try {
+ await api.pending.rejectOne(id);
+ setChanges((prev) =>
+ prev.map((c) => (c.id === id ? { ...c, status: 'rejected' as const } : c)),
+ );
+ } catch (err) {
+ console.error('reject failed:', err);
+ }
+ };
+
+ const handleRewindOne = async (id: string) => {
+ try {
+ await api.pending.rewindOne(id);
+ setChanges((prev) =>
+ prev.map((c) => (c.id === id ? { ...c, status: 'reverted' as const } : c)),
+ );
+ } catch (err) {
+ console.error('rewind failed:', err);
+ }
+ };
+
+ const handleApplyAll = async () => {
+ try {
+ const result = await api.pending.applyAll(sessionId);
+ const appliedIds = new Set(
+ result.results.filter((r) => r.success).map((r) => r.id),
+ );
+ setChanges((prev) =>
+ prev.map((c) =>
+ appliedIds.has(c.id) ? { ...c, status: 'applied' as const } : c,
+ ),
+ );
+ } catch (err) {
+ console.error('apply all failed:', err);
+ }
+ };
+
+ const handleRejectAll = async () => {
+ // Reject each pending change individually (no batch reject endpoint)
+ for (const c of pendingChanges) {
+ await handleRejectOne(c.id);
+ }
+ };
+
+ const OpIcon = ({ op }: { op: PendingChange['operation'] }) => {
+ switch (op) {
+ case 'create':
+ return ;
+ case 'edit':
+ return ;
+ case 'delete':
+ return ;
+ }
+ };
+
+ const StatusBadge = ({ status }: { status: PendingChange['status'] }) => {
+ const colors: Record = {
+ pending: 'bg-yellow-500/20 text-yellow-400',
+ applied: 'bg-green-500/20 text-green-400',
+ rejected: 'bg-zinc-500/20 text-zinc-400',
+ reverted: 'bg-orange-500/20 text-orange-400',
+ };
+ return (
+
+ {status}
+
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+
+ Pending Changes
+ {pendingChanges.length > 0 && (
+
+ ({pendingChanges.length})
+
+ )}
+
+
+
+ {pendingChanges.length > 0 && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {/* Changes list */}
+
+ {loading && (
+
Loading...
+ )}
+
+ {!loading && changes.length === 0 && (
+
+ No pending changes yet.
+
+ )}
+
+ {/* Pending changes first */}
+ {pendingChanges.map((change) => (
+
+ setExpandedId((prev) => (prev === change.id ? null : change.id))
+ }
+ onApply={() => handleApplyOne(change.id)}
+ onReject={() => handleRejectOne(change.id)}
+ OpIcon={OpIcon}
+ StatusBadge={StatusBadge}
+ />
+ ))}
+
+ {/* Resolved changes */}
+ {resolvedChanges.length > 0 && pendingChanges.length > 0 && (
+
+ )}
+ {resolvedChanges.map((change) => (
+
+ setExpandedId((prev) => (prev === change.id ? null : change.id))
+ }
+ onRewind={
+ change.status === 'applied'
+ ? () => handleRewindOne(change.id)
+ : undefined
+ }
+ OpIcon={OpIcon}
+ StatusBadge={StatusBadge}
+ />
+ ))}
+
+
+ );
+}
+
+interface ChangeItemProps {
+ change: PendingChange;
+ expanded: boolean;
+ onToggle: () => void;
+ onApply?: () => void;
+ onReject?: () => void;
+ onRewind?: () => void;
+ OpIcon: React.ComponentType<{ op: PendingChange['operation'] }>;
+ StatusBadge: React.ComponentType<{ status: PendingChange['status'] }>;
+}
+
+function ChangeItem({
+ change,
+ expanded,
+ onToggle,
+ onApply,
+ onReject,
+ onRewind,
+ OpIcon,
+ StatusBadge,
+}: ChangeItemProps) {
+ const fileName = change.file_path.split('/').pop() || change.file_path;
+ const dirPath = change.file_path.split('/').slice(0, -1).join('/');
+
+ return (
+
+
+
+
+
+ {fileName}
+
+ {dirPath && (
+
+ {dirPath}
+
+ )}
+
+
+ {change.status === 'pending' && (
+
+
+
+
+ )}
+ {change.status === 'applied' && onRewind && (
+
+ )}
+
+
+ {expanded && (
+
+ {change.operation === 'edit' && (
+
+ {change.old_string && (
+
+
+ Remove
+
+
+ {change.old_string}
+
+
+ )}
+ {change.new_string && (
+
+
+ Add
+
+
+ {change.new_string}
+
+
+ )}
+
+ )}
+ {change.operation === 'create' && change.content && (
+
+
+ New file
+
+
+ {change.content.length > 2000
+ ? change.content.slice(0, 2000) + '\n... (truncated)'
+ : change.content}
+
+
+ )}
+ {change.operation === 'delete' && (
+
+ This file will be deleted.
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/coder/web/src/components/Layout.tsx b/apps/coder/web/src/components/Layout.tsx
new file mode 100644
index 0000000..5ab3ac0
--- /dev/null
+++ b/apps/coder/web/src/components/Layout.tsx
@@ -0,0 +1,62 @@
+import { useState } from 'react';
+import { Code2, MessageSquare, GitPullRequest } from 'lucide-react';
+
+interface Props {
+ chatPane: React.ReactNode;
+ diffPane: React.ReactNode;
+}
+
+export function Layout({ chatPane, diffPane }: Props) {
+ const [activeTab, setActiveTab] = useState<'chat' | 'diff'>('chat');
+
+ return (
+
+ {/* Top bar */}
+
+
+ {/* Mobile tab bar (visible below lg breakpoint) */}
+
+
+
+
+
+ {/* Desktop split layout */}
+
+
+ {chatPane}
+
+
+ {diffPane}
+
+
+
+ {/* Mobile: show only the active tab */}
+
+ {activeTab === 'chat' ? chatPane : diffPane}
+
+
+ );
+}
diff --git a/apps/coder/web/src/components/MessageBubble.tsx b/apps/coder/web/src/components/MessageBubble.tsx
new file mode 100644
index 0000000..08e0098
--- /dev/null
+++ b/apps/coder/web/src/components/MessageBubble.tsx
@@ -0,0 +1,115 @@
+import Markdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import type { Message } from '@/api/types';
+import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
+
+interface Props {
+ message: Message;
+}
+
+export function MessageBubble({ message }: Props) {
+ if (message.role === 'tool') {
+ return ;
+ }
+
+ const isUser = message.role === 'user';
+ const isStreaming = message.status === 'streaming';
+ const isFailed = message.status === 'failed';
+
+ return (
+
+
+ {isFailed && (
+
+ )}
+
+ {message.tool_calls && message.tool_calls.length > 0 && (
+
+ {message.tool_calls.map((tc) => (
+
+
+ {tc.name}
+
+ {truncateArgs(tc.arguments)}
+
+
+ ))}
+
+ )}
+
+ {message.content.trim() && (
+
+ {message.content}
+
+ )}
+
+ {isStreaming && !message.content.trim() && (
+
+
+ Thinking...
+
+ )}
+
+ {isStreaming && message.content.trim() && (
+
+ )}
+
+
+ );
+}
+
+function ToolResultBubble({ message }: Props) {
+ const result = message.tool_results;
+ if (!result) return null;
+
+ const isError = result.error;
+ const output = result.output || '';
+ const displayOutput =
+ output.length > 300 ? output.slice(0, 300) + '...' : output;
+
+ return (
+
+
+ {result.truncated && (
+
+ [truncated]
+
+ )}
+
{displayOutput}
+
+
+ );
+}
+
+function truncateArgs(args: string): string {
+ if (!args) return '';
+ try {
+ const parsed = JSON.parse(args);
+ const keys = Object.keys(parsed);
+ if (keys.length === 0) return '';
+ const first = keys[0]!;
+ const val = String(parsed[first]);
+ const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
+ return `${first}: ${display}`;
+ } catch {
+ return args.length > 50 ? args.slice(0, 50) + '...' : args;
+ }
+}
diff --git a/apps/coder/web/src/globals.css b/apps/coder/web/src/globals.css
new file mode 100644
index 0000000..6845eb6
--- /dev/null
+++ b/apps/coder/web/src/globals.css
@@ -0,0 +1,22 @@
+@import "tailwindcss";
+
+body {
+ margin: 0;
+ min-height: 100vh;
+}
+
+/* Scrollbar styling for dark theme */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: #3f3f46;
+ border-radius: 4px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #52525b;
+}
diff --git a/apps/coder/web/src/hooks/useSessionStream.ts b/apps/coder/web/src/hooks/useSessionStream.ts
new file mode 100644
index 0000000..873331d
--- /dev/null
+++ b/apps/coder/web/src/hooks/useSessionStream.ts
@@ -0,0 +1,230 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+import type { Message, WsFrame, PendingChange } from '@/api/types';
+
+interface State {
+ messages: Message[];
+ connected: boolean;
+ error: string | null;
+}
+
+function applyFrame(state: State, frame: WsFrame): State {
+ switch (frame.type) {
+ case 'snapshot': {
+ return { ...state, messages: frame.messages };
+ }
+ case 'message_started': {
+ const exists = state.messages.some((m) => m.id === frame.message_id);
+ if (exists) return state;
+ const newMsg: Message = {
+ id: frame.message_id,
+ session_id: '',
+ chat_id: frame.chat_id,
+ role: frame.role,
+ content: '',
+ kind: 'message',
+ tool_calls: null,
+ tool_results: null,
+ status: frame.role === 'system' ? 'complete' : 'streaming',
+ tokens_used: null,
+ ctx_used: null,
+ ctx_max: null,
+ started_at: null,
+ finished_at: null,
+ created_at: new Date().toISOString(),
+ metadata: null,
+ };
+ return { ...state, messages: [...state.messages, newMsg] };
+ }
+ case 'delta': {
+ const next = state.messages.map((m) =>
+ m.id === frame.message_id ? { ...m, content: m.content + frame.content } : m,
+ );
+ return { ...state, messages: next };
+ }
+ case 'tool_call': {
+ const next = state.messages.map((m) =>
+ m.id === frame.message_id
+ ? { ...m, tool_calls: [...(m.tool_calls ?? []), frame.tool_call] }
+ : m,
+ );
+ return { ...state, messages: next };
+ }
+ case 'tool_result': {
+ const exists = state.messages.some((m) => m.id === frame.tool_message_id);
+ if (exists) {
+ const next = state.messages.map((m) =>
+ m.id === frame.tool_message_id
+ ? {
+ ...m,
+ role: 'tool' as const,
+ tool_results: {
+ tool_call_id: frame.tool_call_id,
+ output: frame.output,
+ truncated: frame.truncated,
+ ...(frame.error ? { error: frame.error } : {}),
+ },
+ status: 'complete' as const,
+ }
+ : m,
+ );
+ return { ...state, messages: next };
+ }
+ const newMsg: Message = {
+ id: frame.tool_message_id,
+ session_id: '',
+ chat_id: frame.chat_id,
+ role: 'tool',
+ content: '',
+ kind: 'message',
+ tool_calls: null,
+ tool_results: {
+ tool_call_id: frame.tool_call_id,
+ output: frame.output,
+ truncated: frame.truncated,
+ ...(frame.error ? { error: frame.error } : {}),
+ },
+ status: 'complete',
+ tokens_used: null,
+ ctx_used: null,
+ ctx_max: null,
+ started_at: null,
+ finished_at: null,
+ created_at: new Date().toISOString(),
+ metadata: null,
+ };
+ return { ...state, messages: [...state.messages, newMsg] };
+ }
+ case 'message_complete': {
+ const next = state.messages.map((m) =>
+ m.id === frame.message_id
+ ? {
+ ...m,
+ status: 'complete' as const,
+ ...(frame.tokens_used !== undefined ? { tokens_used: frame.tokens_used } : {}),
+ ...(frame.ctx_used !== undefined ? { ctx_used: frame.ctx_used } : {}),
+ ...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
+ ...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
+ ...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
+ ...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
+ }
+ : m,
+ );
+ return { ...state, messages: next };
+ }
+ case 'error': {
+ const next = frame.message_id
+ ? state.messages.map((m) =>
+ m.id === frame.message_id ? { ...m, status: 'failed' as const } : m,
+ )
+ : state.messages;
+ return { ...state, messages: next, error: frame.error };
+ }
+ case 'pending_change_added':
+ case 'pending_change_updated':
+ // These are handled by the pending changes listener, not the message state
+ return state;
+ }
+}
+
+const RECONNECT_INITIAL_MS = 1000;
+const RECONNECT_MAX_MS = 30_000;
+
+interface SessionStreamResult {
+ messages: Message[];
+ connected: boolean;
+ error: string | null;
+ isStreaming: boolean;
+ /** Listeners for pending change frames */
+ onPendingChange: (cb: (change: PendingChange) => void) => () => void;
+}
+
+export function useSessionStream(sessionId: string | undefined): SessionStreamResult {
+ const [state, setState] = useState({ messages: [], connected: false, error: null });
+ const wsRef = useRef(null);
+ const pendingListenersRef = useRef void>>(new Set());
+
+ useEffect(() => {
+ if (!sessionId) return;
+
+ setState({ messages: [], connected: false, error: null });
+
+ let unmounted = false;
+ let reconnectTimer: ReturnType | null = null;
+ let reconnectDelay = RECONNECT_INITIAL_MS;
+
+ const connect = () => {
+ if (unmounted) return;
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
+ const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
+ const ws = new WebSocket(url);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ reconnectDelay = RECONNECT_INITIAL_MS;
+ setState((s) => ({ ...s, connected: true, error: null }));
+ };
+
+ ws.onmessage = (ev) => {
+ let frame: WsFrame;
+ try {
+ frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
+ } catch {
+ return;
+ }
+
+ // Notify pending change listeners
+ if (frame.type === 'pending_change_added' || frame.type === 'pending_change_updated') {
+ for (const cb of pendingListenersRef.current) {
+ cb(frame.change);
+ }
+ }
+
+ setState((s) => applyFrame(s, frame));
+ };
+
+ ws.onerror = () => {
+ try {
+ ws.close();
+ } catch {}
+ };
+
+ ws.onclose = () => {
+ if (unmounted) return;
+ setState((s) => ({ ...s, connected: false }));
+ const delay = reconnectDelay;
+ reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
+ reconnectTimer = setTimeout(connect, delay);
+ };
+ };
+
+ connect();
+
+ return () => {
+ unmounted = true;
+ if (reconnectTimer) clearTimeout(reconnectTimer);
+ const ws = wsRef.current;
+ wsRef.current = null;
+ if (ws)
+ try {
+ ws.close();
+ } catch {}
+ };
+ }, [sessionId]);
+
+ const isStreaming = state.messages.some((m) => m.status === 'streaming');
+
+ const onPendingChange = useCallback((cb: (change: PendingChange) => void) => {
+ pendingListenersRef.current.add(cb);
+ return () => {
+ pendingListenersRef.current.delete(cb);
+ };
+ }, []);
+
+ return {
+ messages: state.messages,
+ connected: state.connected,
+ error: state.error,
+ isStreaming,
+ onPendingChange,
+ };
+}
diff --git a/apps/coder/web/src/main.tsx b/apps/coder/web/src/main.tsx
new file mode 100644
index 0000000..cc9cbb7
--- /dev/null
+++ b/apps/coder/web/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import { App } from './App';
+import './globals.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+);
diff --git a/apps/coder/web/src/pages/Home.tsx b/apps/coder/web/src/pages/Home.tsx
new file mode 100644
index 0000000..4a7cef3
--- /dev/null
+++ b/apps/coder/web/src/pages/Home.tsx
@@ -0,0 +1,138 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Code2, Folder, ArrowRight } from 'lucide-react';
+import type { Project, Session } from '@/api/types';
+import { api } from '@/api/client';
+
+export function Home() {
+ const navigate = useNavigate();
+ const [projects, setProjects] = useState([]);
+ const [sessions, setSessions] = useState([]);
+ const [selectedProject, setSelectedProject] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ // Fetch projects on mount
+ useEffect(() => {
+ api.projects
+ .list({ status: 'open' })
+ .then(setProjects)
+ .catch(console.error)
+ .finally(() => setLoading(false));
+ }, []);
+
+ // Fetch sessions when a project is selected
+ useEffect(() => {
+ if (!selectedProject) {
+ setSessions([]);
+ return;
+ }
+ api.sessions
+ .listForProject(selectedProject, 'open')
+ .then(setSessions)
+ .catch(console.error);
+ }, [selectedProject]);
+
+ const handleSessionClick = (session: Session) => {
+ navigate(`/sessions/${session.id}`);
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
BooCoder
+
+
+ {/* Projects list */}
+
+
+ Projects
+
+ {projects.length === 0 ? (
+
+ No projects found. Create one in BooChat first.
+
+ ) : (
+
+ {projects.map((project) => (
+
+ ))}
+
+ )}
+
+
+ {/* Sessions list */}
+ {selectedProject && (
+
+
+ Sessions
+
+ {sessions.length === 0 ? (
+
+ No open sessions. Create one in BooChat first.
+
+ ) : (
+
+ {sessions.map((session) => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/apps/coder/web/src/pages/Session.tsx b/apps/coder/web/src/pages/Session.tsx
new file mode 100644
index 0000000..da8b17c
--- /dev/null
+++ b/apps/coder/web/src/pages/Session.tsx
@@ -0,0 +1,86 @@
+import { useEffect, useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { ArrowLeft } from 'lucide-react';
+import type { Chat } from '@/api/types';
+import { api } from '@/api/client';
+import { useSessionStream } from '@/hooks/useSessionStream';
+import { ChatPane } from '@/components/ChatPane';
+import { DiffPane } from '@/components/DiffPane';
+import { Layout } from '@/components/Layout';
+
+export function Session() {
+ const { sessionId } = useParams<{ sessionId: string }>();
+ const navigate = useNavigate();
+ const [chat, setChat] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const { messages, connected, isStreaming, onPendingChange } =
+ useSessionStream(sessionId);
+
+ // Get or create a chat for this session
+ useEffect(() => {
+ if (!sessionId) return;
+
+ api.chats
+ .listForSession(sessionId)
+ .then((chats) => {
+ // Use the first open chat, or create one
+ const openChat = chats.find((c) => c.status === 'open');
+ if (openChat) {
+ setChat(openChat);
+ } else {
+ // Create a new chat
+ return api.chats.create(sessionId).then((newChat) => {
+ setChat(newChat);
+ });
+ }
+ })
+ .catch(console.error)
+ .finally(() => setLoading(false));
+ }, [sessionId]);
+
+ if (!sessionId) {
+ navigate('/');
+ return null;
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!chat) {
+ return (
+
+
Could not load chat for this session.
+
+
+ );
+ }
+
+ return (
+
+ }
+ diffPane={
+
+ }
+ />
+ );
+}
diff --git a/apps/coder/web/src/vite-env.d.ts b/apps/coder/web/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/coder/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/coder/web/tsconfig.app.json b/apps/coder/web/tsconfig.app.json
new file mode 100644
index 0000000..cf600e6
--- /dev/null
+++ b/apps/coder/web/tsconfig.app.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "allowSyntheticDefaultImports": true,
+ "isolatedModules": true,
+ "noUncheckedIndexedAccess": true,
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "noEmit": true,
+ "useDefineForClassFields": true,
+ "allowImportingTsExtensions": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/apps/coder/web/tsconfig.json b/apps/coder/web/tsconfig.json
new file mode 100644
index 0000000..fec8c8e
--- /dev/null
+++ b/apps/coder/web/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/apps/coder/web/tsconfig.node.json b/apps/coder/web/tsconfig.node.json
new file mode 100644
index 0000000..cee403b
--- /dev/null
+++ b/apps/coder/web/tsconfig.node.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/coder/web/vite.config.d.ts b/apps/coder/web/vite.config.d.ts
new file mode 100644
index 0000000..340562a
--- /dev/null
+++ b/apps/coder/web/vite.config.d.ts
@@ -0,0 +1,2 @@
+declare const _default: import("vite").UserConfig;
+export default _default;
diff --git a/apps/coder/web/vite.config.js b/apps/coder/web/vite.config.js
new file mode 100644
index 0000000..f78e97b
--- /dev/null
+++ b/apps/coder/web/vite.config.js
@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'node:path';
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ port: 5174,
+ proxy: {
+ '/api': {
+ target: 'http://127.0.0.1:3000',
+ changeOrigin: true,
+ ws: true,
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ },
+});
diff --git a/apps/coder/web/vite.config.ts b/apps/coder/web/vite.config.ts
new file mode 100644
index 0000000..134f516
--- /dev/null
+++ b/apps/coder/web/vite.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'node:path';
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ port: 5174,
+ proxy: {
+ '/api': {
+ target: 'http://127.0.0.1:3000',
+ changeOrigin: true,
+ ws: true,
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5447f6f..4d3dadb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -80,6 +80,49 @@ importers:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))
+ apps/coder/web:
+ dependencies:
+ lucide-react:
+ specifier: ^1.16.0
+ version: 1.16.0(react@18.3.1)
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@18.3.28)(react@18.3.1)
+ react-router-dom:
+ specifier: ^6.26.0
+ version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ remark-gfm:
+ specifier: ^4.0.1
+ version: 4.0.1
+ devDependencies:
+ '@tailwindcss/postcss':
+ specifier: ^4.3.0
+ version: 4.3.0
+ '@types/react':
+ specifier: ^18.3.3
+ version: 18.3.28
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.7(@types/react@18.3.28)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.1
+ version: 4.7.0(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0))
+ tailwindcss:
+ specifier: ^4.3.0
+ version: 4.3.0
+ typescript:
+ specifier: ^5.5.0
+ version: 5.9.3
+ vite:
+ specifier: ^5.3.4
+ version: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
+
apps/server:
dependencies:
'@ai-sdk/openai-compatible':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 06b6051..af4ada7 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,3 @@
packages:
- "apps/*"
+ - "apps/coder/web"