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

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