Files
boocode/apps/coder/web/src/components/ChatPane.tsx
indifferentketchup 78455b7efc 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>
2026-05-25 03:04:52 +00:00

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