"use client"; import { AppSidebar } from "@/components/app-sidebar"; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger, } from "@/components/ui/sidebar"; import { PromptInput, PromptInputBody, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputSubmit, PromptInputAttachments, PromptInputAttachment, PromptInputActionMenu, PromptInputActionMenuTrigger, PromptInputActionMenuContent, PromptInputActionAddAttachments, PromptInputHeader, type PromptInputMessage, } from "@/components/ai-elements/prompt-input"; import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; import { Conversation, ConversationContent } from "@/components/ai-elements/conversation"; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from "@/components/ai-elements/tool"; import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning"; import { useState, useEffect, useRef } from "react"; import { GlobeIcon, MicIcon } from "lucide-react"; import { RunEvent } from "@/lib/cli-client"; interface ChatMessage { id: string; type: 'message'; role: 'user' | 'assistant'; content: string; timestamp: number; } interface ToolCall { id: string; type: 'tool'; name: string; input: any; result?: any; status: 'pending' | 'running' | 'completed' | 'error'; timestamp: number; } interface ReasoningBlock { id: string; type: 'reasoning'; content: string; isStreaming: boolean; timestamp: number; } type ConversationItem = ChatMessage | ToolCall | ReasoningBlock; export default function HomePage() { const [text, setText] = useState(""); const [useWebSearch, setUseWebSearch] = useState(false); const [useMicrophone, setUseMicrophone] = useState(false); const [status, setStatus] = useState< "submitted" | "streaming" | "ready" | "error" >("ready"); // Chat state const [runId, setRunId] = useState(null); const [conversation, setConversation] = useState([]); const [currentAssistantMessage, setCurrentAssistantMessage] = useState(""); const [currentReasoning, setCurrentReasoning] = useState(""); const eventSourceRef = useRef(null); const committedMessageIds = useRef>(new Set()); const isEmptyConversation = conversation.length === 0 && !currentAssistantMessage && !currentReasoning; const renderPromptInput = () => ( {(attachment) => } setText(event.target.value)} value={text} placeholder="Ask me anything..." className="min-h-[46px] max-h-[200px]" /> setUseMicrophone(!useMicrophone)} variant={useMicrophone ? "default" : "ghost"} > Microphone setUseWebSearch(!useWebSearch)} variant={useWebSearch ? "default" : "ghost"} > Search ); // Connect to SSE stream useEffect(() => { // Prevent multiple connections if (eventSourceRef.current) { console.log('⚠️ EventSource already exists, not creating new one'); return; } console.log('🔌 Creating new EventSource connection'); const eventSource = new EventSource('/api/stream'); eventSourceRef.current = eventSource; const handleMessage = (e: MessageEvent) => { try { const event: RunEvent = JSON.parse(e.data); handleEvent(event); } catch (error) { console.error('Failed to parse event:', error); } }; const handleError = (e: Event) => { const target = e.target as EventSource; // Only log if it's not a normal close if (target.readyState === EventSource.CLOSED) { console.log('SSE connection closed, will reconnect on next message'); } else if (target.readyState === EventSource.CONNECTING) { console.log('SSE reconnecting...'); } else { console.error('SSE error:', e); } }; eventSource.addEventListener('message', handleMessage); eventSource.addEventListener('error', handleError); return () => { console.log('🔌 Closing EventSource connection'); eventSource.removeEventListener('message', handleMessage); eventSource.removeEventListener('error', handleError); eventSource.close(); eventSourceRef.current = null; }; }, []); // Empty deps - only run once // Handle different event types from the copilot const handleEvent = (event: RunEvent) => { console.log('Event received:', event.type, event); switch (event.type) { case 'start': setStatus('streaming'); setCurrentAssistantMessage(''); setCurrentReasoning(''); break; case 'llm-stream-event': console.log('LLM stream event type:', event.event?.type); if (event.event?.type === 'reasoning-delta') { setCurrentReasoning(prev => prev + event.event.delta); } else if (event.event?.type === 'reasoning-end') { // Commit reasoning block if we have content setCurrentReasoning(reasoning => { if (reasoning) { setConversation(prev => [...prev, { id: `reasoning-${Date.now()}`, type: 'reasoning', content: reasoning, isStreaming: false, timestamp: Date.now(), }]); } return ''; }); } else if (event.event?.type === 'text-delta') { setCurrentAssistantMessage(prev => prev + event.event.delta); setStatus('streaming'); } else if (event.event?.type === 'text-end') { console.log('TEXT END received - waiting for message event'); } else if (event.event?.type === 'tool-call') { // Add tool call to conversation immediately setConversation(prev => [...prev, { id: event.event.toolCallId, type: 'tool', name: event.event.toolName, input: event.event.input, status: 'running', timestamp: Date.now(), }]); } else if (event.event?.type === 'finish-step') { console.log('FINISH STEP received - waiting for message event'); } break; case 'message': console.log('MESSAGE event received:', event); if (event.message?.role === 'assistant') { // If the final assistant message contains tool calls, sync them to conversation if (Array.isArray(event.message.content)) { const toolCalls = event.message.content.filter( (part: any) => part?.type === 'tool-call' ); if (toolCalls.length) { setConversation((prev) => { const updated = [...prev]; for (const part of toolCalls) { const idx = updated.findIndex( (item) => item.type === 'tool' && item.id === part.toolCallId ); if (idx >= 0) { updated[idx] = { ...updated[idx], name: part.toolName, input: part.arguments, status: 'pending', }; } else { updated.push({ id: part.toolCallId, type: 'tool', name: part.toolName, input: part.arguments, status: 'pending', timestamp: Date.now(), }); } } return updated; }); } } const messageId = event.messageId || `assistant-${Date.now()}`; if (committedMessageIds.current.has(messageId)) { console.log('⚠️ Message already committed, skipping:', messageId); return; } committedMessageIds.current.add(messageId); setCurrentAssistantMessage(currentMsg => { console.log('✅ Committing message:', messageId, currentMsg); if (currentMsg) { setConversation(prev => { const exists = prev.some(m => m.id === messageId); if (exists) { console.log('⚠️ Message ID already in array, skipping:', messageId); return prev; } return [...prev, { id: messageId, type: 'message', role: 'assistant', content: currentMsg, timestamp: Date.now(), }]; }); } return ''; }); setStatus('ready'); console.log('Status set to ready'); } break; case 'tool-invocation': setConversation(prev => prev.map(item => item.type === 'tool' && (item.id === event.toolCallId || item.name === event.toolName) ? { ...item, status: 'running' as const } : item )); break; case 'tool-result': setConversation(prev => prev.map(item => item.type === 'tool' && (item.id === event.toolCallId || item.name === event.toolName) ? { ...item, result: event.result, status: 'completed' as const } : item )); break; case 'error': // Only set error status for actual errors, not connection issues if (event.error && !event.error.includes('terminated')) { setStatus('error'); console.error('Agent error:', event.error); } else { console.log('Connection error (will auto-reconnect):', event.error); setStatus('ready'); } break; default: console.log('Unhandled event type:', event.type); } }; const handleSubmit = async (message: PromptInputMessage) => { const hasText = Boolean(message.text); const hasAttachments = Boolean(message.files?.length); if (!(hasText || hasAttachments)) { return; } const userMessage = message.text || ''; // Add user message immediately with unique ID const userMessageId = `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; setConversation(prev => [...prev, { id: userMessageId, type: 'message', role: 'user', content: userMessage, timestamp: Date.now(), }]); setStatus("submitted"); setText(""); try { // Send message to backend const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: userMessage, runId: runId, }), }); if (!response.ok) { throw new Error('Failed to send message'); } const data = await response.json(); // Store runId for subsequent messages if (data.runId && !runId) { setRunId(data.runId); } setStatus('streaming'); } catch (error) { console.error('Failed to send message:', error); setStatus('error'); setTimeout(() => setStatus('ready'), 2000); } }; return (
RowboatX Chat
{/* Messages area */}
{/* Render conversation items in order */} {conversation.map((item) => { if (item.type === 'message') { return ( {item.content} ); } else if (item.type === 'tool') { const stateMap: Record = { 'pending': 'input-streaming', 'running': 'input-available', 'completed': 'output-available', 'error': 'output-error', }; return (
{item.result && ( )}
); } else if (item.type === 'reasoning') { return (
{item.content}
); } return null; })} {/* Streaming reasoning */} {currentReasoning && (
{currentReasoning}
)} {/* Streaming message */} {currentAssistantMessage && ( {currentAssistantMessage} )}
{/* Input area */} {isEmptyConversation ? (

RowboatX

{renderPromptInput()}
) : (
{renderPromptInput()}
)}
); }