416 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|