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>
132 lines
4.1 KiB
TypeScript
132 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
}
|