gadget/gadget-code/frontend/src/pages/ChatSessionView.tsx
2026-04-29 18:59:42 -04:00

416 lines
13 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { socketClient } from '../lib/socket';
import { chatSessionApi, projectApi, type ChatSession, type ChatTurn, type Project } from '../lib/api';
interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
thinking?: string;
toolCalls?: Array<{
callId: string;
name: string;
parameters?: string;
response?: string;
}>;
timestamp: Date;
}
export default function ChatSessionView() {
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
const navigate = useNavigate();
const socket = socketClient.socket;
const [project, setProject] = useState<Project | null>(null);
const [session, setSession] = useState<ChatSession | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [promptInput, setPromptInput] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
loadSessionData();
}, [projectId, sessionId]);
useEffect(() => {
setupSocketListeners();
return () => cleanupSocketListeners();
}, [sessionId]);
useEffect(() => {
scrollToBottom();
}, [messages]);
const loadSessionData = async () => {
try {
// Load project
if (projectId) {
const projectData = await projectApi.get(projectId);
setProject(projectData);
}
// Load chat session
if (sessionId) {
const sessionData = await chatSessionApi.get(sessionId);
setSession(sessionData);
// Load existing turns
const turns = await chatSessionApi.getTurns(sessionId);
const chatMessages: ChatMessage[] = turns.map((turn: ChatTurn) => ({
id: turn._id,
role: 'user',
content: turn.prompts.user,
timestamp: new Date(turn.createdAt),
}));
// Add assistant responses
turns.forEach((turn: ChatTurn) => {
if (turn.thinking || turn.response || turn.toolCalls?.length) {
chatMessages.push({
id: `${turn._id}-response`,
role: 'assistant',
content: turn.response || '',
thinking: turn.thinking,
toolCalls: turn.toolCalls,
timestamp: new Date(turn.createdAt),
});
}
});
setMessages(chatMessages);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load session');
} finally {
setLoading(false);
}
};
const setupSocketListeners = () => {
if (!socket) return;
socket.on('thinking', handleThinking);
socket.on('response', handleResponse);
socket.on('toolCall', handleToolCall);
socket.on('workOrderComplete', handleWorkOrderComplete);
};
const cleanupSocketListeners = () => {
if (!socket) return;
socket.off('thinking', handleThinking);
socket.off('response', handleResponse);
socket.off('toolCall', handleToolCall);
socket.off('workOrderComplete', handleWorkOrderComplete);
};
const handleThinking = (content: string) => {
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
return [
...prev.slice(0, -1),
{
...lastMessage,
thinking: (lastMessage.thinking || '') + content,
},
];
}
return prev;
});
};
const handleResponse = (content: string) => {
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
return [
...prev.slice(0, -1),
{
...lastMessage,
content: lastMessage.content + content,
},
];
}
return prev;
});
};
const handleToolCall = (callId: string, name: string, params: string, response: string) => {
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
const toolCalls = lastMessage.toolCalls || [];
toolCalls.push({ callId, name, parameters: params, response });
return [
...prev.slice(0, -1),
{
...lastMessage,
toolCalls,
},
];
}
return prev;
});
};
const handleWorkOrderComplete = (turnId: string, success: boolean, message?: string) => {
setIsProcessing(false);
if (!success) {
setError(message || 'Work order failed');
}
};
const handleSubmitPrompt = async (e: React.FormEvent) => {
e.preventDefault();
if (!promptInput.trim() || isProcessing || !socket) return;
const userMessage: ChatMessage = {
id: `temp-${Date.now()}`,
role: 'user',
content: promptInput.trim(),
timestamp: new Date(),
};
setMessages(prev => [...prev, userMessage]);
setPromptInput('');
setIsProcessing(true);
setError('');
// Add assistant message placeholder
setMessages(prev => [...prev, {
id: `response-${Date.now()}`,
role: 'assistant',
content: '',
timestamp: new Date(),
}]);
// Emit prompt to backend
socketClient.emitServer('submitPrompt', promptInput.trim());
};
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-bg-primary">
<p className="text-text-muted">Loading chat session...</p>
</div>
);
}
if (error && !session) {
return (
<div className="flex-1 flex items-center justify-center bg-bg-primary">
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={() => navigate(`/projects/${projectId}`)}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors"
>
Back to Project
</button>
</div>
</div>
);
}
return (
<div className="flex-1 flex bg-bg-primary overflow-hidden">
{/* Main Chat Area */}
<div className="flex-1 flex flex-col">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<ChatMessageBubble key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</div>
{/* Prompt Input */}
<div className="border-t border-border-subtle p-4 bg-bg-secondary">
<form onSubmit={handleSubmitPrompt} className="flex gap-2">
<textarea
ref={inputRef}
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmitPrompt(e);
}
}}
placeholder="Enter your prompt... (Shift+Enter for new line)"
className="flex-1 px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none resize-none"
rows={3}
disabled={isProcessing}
/>
<button
type="submit"
disabled={isProcessing || !promptInput.trim()}
className="px-6 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? 'Processing...' : 'Send'}
</button>
</form>
</div>
</div>
{/* Session Sidebar */}
<div className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col overflow-y-auto">
<SessionSidebar session={session} project={project} />
</div>
</div>
);
}
function ChatMessageBubble({ message }: { message: ChatMessage }) {
const [thinkingExpanded, setThinkingExpanded] = useState(false);
return (
<div
className={`max-w-3xl ${
message.role === 'user' ? 'ml-auto' : ''
}`}
>
<div
className={`rounded p-4 ${
message.role === 'user'
? 'bg-brand text-white'
: 'bg-bg-tertiary border border-border-default'
}`}
>
{message.role === 'assistant' && message.thinking && (
<div className="mb-3">
<button
onClick={() => setThinkingExpanded(!thinkingExpanded)}
className="text-xs text-text-muted hover:text-text-secondary flex items-center gap-1"
>
<span className={`transform transition-transform ${thinkingExpanded ? 'rotate-90' : ''}`}></span>
Thinking ({message.thinking.length} chars)
</button>
{thinkingExpanded && (
<div className="mt-2 p-3 bg-bg-secondary rounded text-sm text-text-secondary whitespace-pre-wrap">
{message.thinking}
</div>
)}
</div>
)}
<div className="whitespace-pre-wrap">{message.content}</div>
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{message.toolCalls.map((toolCall, idx) => (
<div
key={toolCall.callId || idx}
className="p-2 bg-bg-secondary rounded border border-border-default"
>
<div className="text-xs font-mono text-text-secondary">
<span className="text-brand"></span> {toolCall.name}
{toolCall.response && (
<span className="ml-2 text-green-500"></span>
)}
</div>
</div>
))}
</div>
)}
<div className="mt-2 text-xs text-text-muted">
{message.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
);
}
function SessionSidebar({ session, project }: { session: ChatSession | null; project: Project | null }) {
if (!session) {
return (
<div className="p-4">
<p className="text-text-muted">No session loaded</p>
</div>
);
}
return (
<div className="p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-2">
Session
</h3>
<div className="space-y-2">
<div>
<div className="text-xs text-text-muted">Name</div>
<div className="text-text-primary">{session.name}</div>
</div>
<div>
<div className="text-xs text-text-muted">ID</div>
<div className="font-mono text-xs text-text-secondary truncate">
{session._id}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Mode</div>
<div className="text-text-primary capitalize">{session.mode}</div>
</div>
</div>
</div>
<div className="border-t border-border-subtle pt-4">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-2">
Model
</h3>
<div className="text-text-primary">
{session.selectedModel}
</div>
<div className="text-xs text-text-muted mt-1">
Provider: {typeof session.provider === 'string' ? 'Loaded' : session.provider?.name}
</div>
</div>
<div className="border-t border-border-subtle pt-4">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-2">
Stats
</h3>
<div className="grid grid-cols-3 gap-2 text-center">
<div>
<div className="text-xs text-text-muted">TC</div>
<div className="text-lg font-mono">{session.stats.toolCallCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">FO</div>
<div className="text-lg font-mono">0</div>
</div>
<div>
<div className="text-xs text-text-muted">SA</div>
<div className="text-lg font-mono">0</div>
</div>
</div>
</div>
<div className="border-t border-border-subtle pt-4">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-2">
Project
</h3>
{project ? (
<div className="space-y-1">
<div className="text-text-primary">{project.name}</div>
<div className="font-mono text-xs text-text-secondary">{project.slug}</div>
</div>
) : (
<div className="text-text-muted text-sm">Loading...</div>
)}
</div>
</div>
);
}