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>
94 lines
2.9 KiB
TypeScript
94 lines
2.9 KiB
TypeScript
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',
|
|
}),
|
|
},
|
|
};
|