v2.0.0: BooCoder frontend — chat pane + diff pane + session picker

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 03:04:52 +00:00
parent d2108b2f8d
commit 78455b7efc
26 changed files with 1574 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ WORKDIR /build
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY apps/server/package.json ./apps/server/ COPY apps/server/package.json ./apps/server/
COPY apps/coder/package.json ./apps/coder/ COPY apps/coder/package.json ./apps/coder/
COPY apps/coder/web/package.json ./apps/coder/web/
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
@@ -15,6 +16,7 @@ COPY apps/server ./apps/server
RUN pnpm -C apps/server build RUN pnpm -C apps/server build
COPY apps/coder ./apps/coder COPY apps/coder ./apps/coder
RUN pnpm -C apps/coder/web build
RUN pnpm -C apps/coder build RUN pnpm -C apps/coder build
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder 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 WORKDIR /app
COPY --from=builder /out/coder ./ COPY --from=builder /out/coder ./
COPY --from=builder /build/apps/coder/web/dist ./web
ENV NODE_ENV=production ENV NODE_ENV=production
EXPOSE 3000 EXPOSE 3000

View File

@@ -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 Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket'; 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 { loadConfig } from './config.js';
import { getSql, applySchema, pingDb, closeDb } from './db.js'; import { getSql, applySchema, pingDb, closeDb } from './db.js';
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the // v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
@@ -111,6 +118,28 @@ async function main() {
registerPendingRoutes(app, sql); registerPendingRoutes(app, sql);
registerWebSocket(app, sql, broker); 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 // Graceful shutdown
const shutdown = async () => { const shutdown = async () => {
app.log.info('shutting down'); app.log.info('shutting down');

12
apps/coder/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BooCoder</title>
</head>
<body class="bg-zinc-900 text-zinc-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

View File

@@ -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 (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/sessions/:sessionId" element={<Session />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
},
sessions: {
listForProject: (projectId: string, status?: 'open' | 'archived') =>
request<Session[]>(
`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`,
),
get: (id: string) => request<Session>(`/api/sessions/${id}`),
},
chats: {
listForSession: (sessionId: string) =>
request<Chat[]>(`/api/sessions/${sessionId}/chats`),
create: (sessionId: string, body?: { name?: string }) =>
request<Chat>(`/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<PendingChange[]>(`/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',
}),
},
};

View File

@@ -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 };

View File

@@ -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<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div className="flex flex-col h-full">
{/* Connection indicator */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800 text-xs text-zinc-500">
<div
className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
/>
<span>{connected ? 'Connected' : 'Disconnected'}</span>
{isStreaming && (
<span className="text-blue-400 ml-auto">Generating...</span>
)}
</div>
{/* Messages list */}
<div className="flex-1 overflow-y-auto px-4 py-4">
{visibleMessages.length === 0 && (
<div className="text-center text-zinc-500 mt-8">
<p className="text-lg font-medium">BooCoder</p>
<p className="text-sm mt-1">Send a message to start coding.</p>
</div>
)}
{visibleMessages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="border-t border-zinc-800 px-4 py-3">
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message BooCoder..."
rows={1}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
disabled={sending}
/>
{isStreaming ? (
<button
onClick={handleStop}
className="p-2 rounded-lg bg-red-600 hover:bg-red-500 text-white transition-colors"
title="Stop generation"
>
<Square size={18} />
</button>
) : (
<button
onClick={handleSend}
disabled={!input.trim() || sending}
className="p-2 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white transition-colors"
title="Send message"
>
<Send size={18} />
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<PendingChange[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(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 <FilePlus size={14} className="text-green-400" />;
case 'edit':
return <FileText size={14} className="text-blue-400" />;
case 'delete':
return <Trash2 size={14} className="text-red-400" />;
}
};
const StatusBadge = ({ status }: { status: PendingChange['status'] }) => {
const colors: Record<PendingChange['status'], string> = {
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 (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colors[status]}`}>
{status}
</span>
);
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
<h2 className="text-sm font-medium text-zinc-300">
Pending Changes
{pendingChanges.length > 0 && (
<span className="ml-1.5 text-xs text-zinc-500">
({pendingChanges.length})
</span>
)}
</h2>
<div className="flex items-center gap-1">
<button
onClick={fetchPending}
className="p-1 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200"
title="Refresh"
>
<RefreshCw size={14} />
</button>
{pendingChanges.length > 0 && (
<>
<button
onClick={handleApplyAll}
className="text-xs px-2 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white"
>
Apply All
</button>
<button
onClick={handleRejectAll}
className="text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300"
>
Reject All
</button>
</>
)}
</div>
</div>
{/* Changes list */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="text-center text-zinc-500 text-sm py-8">Loading...</div>
)}
{!loading && changes.length === 0 && (
<div className="text-center text-zinc-500 text-sm py-8">
No pending changes yet.
</div>
)}
{/* Pending changes first */}
{pendingChanges.map((change) => (
<ChangeItem
key={change.id}
change={change}
expanded={expandedId === change.id}
onToggle={() =>
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 && (
<div className="border-t border-zinc-800 my-1" />
)}
{resolvedChanges.map((change) => (
<ChangeItem
key={change.id}
change={change}
expanded={expandedId === change.id}
onToggle={() =>
setExpandedId((prev) => (prev === change.id ? null : change.id))
}
onRewind={
change.status === 'applied'
? () => handleRewindOne(change.id)
: undefined
}
OpIcon={OpIcon}
StatusBadge={StatusBadge}
/>
))}
</div>
</div>
);
}
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 (
<div className="border-b border-zinc-800/50">
<div
className="flex items-center gap-2 px-4 py-2 hover:bg-zinc-800/50 cursor-pointer"
onClick={onToggle}
>
<OpIcon op={change.operation} />
<div className="flex-1 min-w-0">
<span className="text-sm font-mono text-zinc-200 truncate block">
{fileName}
</span>
{dirPath && (
<span className="text-[11px] text-zinc-500 truncate block">
{dirPath}
</span>
)}
</div>
<StatusBadge status={change.status} />
{change.status === 'pending' && (
<div className="flex items-center gap-1 ml-1">
<button
onClick={(e) => {
e.stopPropagation();
onApply?.();
}}
className="p-1 rounded hover:bg-green-600/30 text-green-400"
title="Apply"
>
<Check size={14} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onReject?.();
}}
className="p-1 rounded hover:bg-red-600/30 text-red-400"
title="Reject"
>
<X size={14} />
</button>
</div>
)}
{change.status === 'applied' && onRewind && (
<button
onClick={(e) => {
e.stopPropagation();
onRewind();
}}
className="p-1 rounded hover:bg-orange-600/30 text-orange-400"
title="Rewind"
>
<RotateCcw size={14} />
</button>
)}
</div>
{expanded && (
<div className="px-4 pb-3">
{change.operation === 'edit' && (
<div className="space-y-2">
{change.old_string && (
<div className="rounded bg-red-950/30 border border-red-900/30 p-2">
<div className="text-[10px] text-red-400 mb-1 font-medium">
Remove
</div>
<pre className="text-xs text-red-200 whitespace-pre-wrap break-all font-mono">
{change.old_string}
</pre>
</div>
)}
{change.new_string && (
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
<div className="text-[10px] text-green-400 mb-1 font-medium">
Add
</div>
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono">
{change.new_string}
</pre>
</div>
)}
</div>
)}
{change.operation === 'create' && change.content && (
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
<div className="text-[10px] text-green-400 mb-1 font-medium">
New file
</div>
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono max-h-60 overflow-y-auto">
{change.content.length > 2000
? change.content.slice(0, 2000) + '\n... (truncated)'
: change.content}
</pre>
</div>
)}
{change.operation === 'delete' && (
<div className="rounded bg-red-950/30 border border-red-900/30 p-2 text-xs text-red-300">
This file will be deleted.
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col h-screen bg-zinc-900">
{/* Top bar */}
<header className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-900/95">
<Code2 size={20} className="text-blue-400" />
<h1 className="text-sm font-semibold text-zinc-200">BooCoder</h1>
</header>
{/* Mobile tab bar (visible below lg breakpoint) */}
<div className="lg:hidden flex border-b border-zinc-800">
<button
onClick={() => setActiveTab('chat')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
activeTab === 'chat'
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-zinc-500'
}`}
>
<MessageSquare size={14} />
Chat
</button>
<button
onClick={() => setActiveTab('diff')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
activeTab === 'diff'
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-zinc-500'
}`}
>
<GitPullRequest size={14} />
Changes
</button>
</div>
{/* Desktop split layout */}
<div className="flex-1 hidden lg:flex overflow-hidden">
<div className="w-[60%] border-r border-zinc-800 overflow-hidden">
{chatPane}
</div>
<div className="w-[40%] overflow-hidden">
{diffPane}
</div>
</div>
{/* Mobile: show only the active tab */}
<div className="flex-1 lg:hidden overflow-hidden">
{activeTab === 'chat' ? chatPane : diffPane}
</div>
</div>
);
}

View File

@@ -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 <ToolResultBubble message={message} />;
}
const isUser = message.role === 'user';
const isStreaming = message.status === 'streaming';
const isFailed = message.status === 'failed';
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
<div
className={`max-w-[85%] rounded-lg px-4 py-2.5 ${
isUser
? 'bg-blue-600 text-white'
: 'bg-zinc-800 text-zinc-100 border border-zinc-700'
}`}
>
{isFailed && (
<div className="flex items-center gap-1.5 text-red-400 text-xs mb-1">
<AlertCircle size={12} />
<span>Failed</span>
</div>
)}
{message.tool_calls && message.tool_calls.length > 0 && (
<div className="mb-2 space-y-1">
{message.tool_calls.map((tc) => (
<div
key={tc.id}
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
>
<Wrench size={11} />
<span className="font-mono">{tc.name}</span>
<span className="text-zinc-500 truncate max-w-[200px]">
{truncateArgs(tc.arguments)}
</span>
</div>
))}
</div>
)}
{message.content.trim() && (
<div className="prose prose-invert prose-sm max-w-none [&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_pre]:overflow-x-auto [&_code]:text-zinc-300 [&_p]:my-1.5">
<Markdown remarkPlugins={[remarkGfm]}>{message.content}</Markdown>
</div>
)}
{isStreaming && !message.content.trim() && (
<div className="flex items-center gap-1.5 text-zinc-400">
<Loader2 size={14} className="animate-spin" />
<span className="text-xs">Thinking...</span>
</div>
)}
{isStreaming && message.content.trim() && (
<span className="inline-block w-1.5 h-4 bg-zinc-400 animate-pulse ml-0.5 align-middle" />
)}
</div>
</div>
);
}
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 (
<div className="flex justify-start mb-2 ml-6">
<div
className={`max-w-[80%] rounded px-3 py-2 text-xs font-mono border ${
isError
? 'bg-red-950/30 border-red-800/50 text-red-300'
: 'bg-zinc-800/50 border-zinc-700/50 text-zinc-400'
}`}
>
{result.truncated && (
<span className="text-yellow-500 text-[10px] block mb-1">
[truncated]
</span>
)}
<pre className="whitespace-pre-wrap break-all">{displayOutput}</pre>
</div>
</div>
);
}
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;
}
}

View File

@@ -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;
}

View File

@@ -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<State>({ messages: [], connected: false, error: null });
const wsRef = useRef<WebSocket | null>(null);
const pendingListenersRef = useRef<Set<(change: PendingChange) => void>>(new Set());
useEffect(() => {
if (!sessionId) return;
setState({ messages: [], connected: false, error: null });
let unmounted = false;
let reconnectTimer: ReturnType<typeof setTimeout> | 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,
};
}

View File

@@ -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(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

View File

@@ -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<Project[]>([]);
const [sessions, setSessions] = useState<Session[]>([]);
const [selectedProject, setSelectedProject] = useState<string | null>(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 (
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
<div className="text-zinc-500">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen bg-zinc-900 p-6">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<Code2 size={28} className="text-blue-400" />
<h1 className="text-2xl font-bold text-zinc-100">BooCoder</h1>
</div>
{/* Projects list */}
<div className="mb-8">
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-3">
Projects
</h2>
{projects.length === 0 ? (
<p className="text-zinc-500 text-sm">
No projects found. Create one in BooChat first.
</p>
) : (
<div className="space-y-1">
{projects.map((project) => (
<button
key={project.id}
onClick={() => setSelectedProject(project.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${
selectedProject === project.id
? 'bg-blue-600/20 border border-blue-500/40'
: 'bg-zinc-800/50 border border-zinc-800 hover:bg-zinc-800'
}`}
>
<Folder
size={16}
className={
selectedProject === project.id
? 'text-blue-400'
: 'text-zinc-500'
}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-zinc-200 truncate">
{project.name}
</div>
<div className="text-xs text-zinc-500 truncate">
{project.path}
</div>
</div>
</button>
))}
</div>
)}
</div>
{/* Sessions list */}
{selectedProject && (
<div>
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-3">
Sessions
</h2>
{sessions.length === 0 ? (
<p className="text-zinc-500 text-sm">
No open sessions. Create one in BooChat first.
</p>
) : (
<div className="space-y-1">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => handleSessionClick(session)}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-zinc-800/50 border border-zinc-800 hover:bg-zinc-800 text-left transition-colors group"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-zinc-200 truncate">
{session.name || 'Untitled session'}
</div>
<div className="text-xs text-zinc-500">
{new Date(session.updated_at).toLocaleDateString()}
</div>
</div>
<ArrowRight
size={16}
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<Chat | null>(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 (
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
<div className="text-zinc-500">Loading session...</div>
</div>
);
}
if (!chat) {
return (
<div className="min-h-screen bg-zinc-900 flex flex-col items-center justify-center gap-4">
<div className="text-zinc-500">Could not load chat for this session.</div>
<button
onClick={() => navigate('/')}
className="flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300"
>
<ArrowLeft size={14} />
Back to projects
</button>
</div>
);
}
return (
<Layout
chatPane={
<ChatPane
sessionId={sessionId}
chatId={chat.id}
messages={messages}
isStreaming={isStreaming}
connected={connected}
/>
}
diffPane={
<DiffPane sessionId={sessionId} onPendingChange={onPendingChange} />
}
/>
);
}

1
apps/coder/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -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"]
}

View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -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"]
}

2
apps/coder/web/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -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,
},
});

View File

@@ -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,
},
});

43
pnpm-lock.yaml generated
View File

@@ -80,6 +80,49 @@ importers:
specifier: ^3.0.0 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)) 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: apps/server:
dependencies: dependencies:
'@ai-sdk/openai-compatible': '@ai-sdk/openai-compatible':

View File

@@ -1,2 +1,3 @@
packages: packages:
- "apps/*" - "apps/*"
- "apps/coder/web"