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>
231 lines
7.0 KiB
TypeScript
231 lines
7.0 KiB
TypeScript
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,
|
|
};
|
|
}
|