'use client'; import { useState, useCallback, useEffect, useRef } from 'react'; import type { Message, SSEEvent, TokenUsage, PermissionRequestEvent } from '@/types'; import { MessageList } from './MessageList'; import { MessageInput } from './MessageInput'; import { usePanel } from '@/hooks/usePanel'; interface ToolUseInfo { id: string; name: string; input: unknown; } interface ToolResultInfo { tool_use_id: string; content: string; } interface ChatViewProps { sessionId: string; initialMessages?: Message[]; modelName?: string; initialMode?: string; } export function ChatView({ sessionId, initialMessages = [], modelName, initialMode }: ChatViewProps) { const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel(); const [messages, setMessages] = useState(initialMessages); const [streamingContent, setStreamingContent] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [toolUses, setToolUses] = useState([]); const [toolResults, setToolResults] = useState([]); const [statusText, setStatusText] = useState(); const [mode, setMode] = useState(initialMode || 'code'); const [currentModel, setCurrentModel] = useState(modelName || 'sonnet'); const [pendingPermission, setPendingPermission] = useState(null); const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null); const [streamingToolOutput, setStreamingToolOutput] = useState(''); const handleModeChange = useCallback((newMode: string) => { setMode(newMode); // Persist mode to database and notify chat list if (sessionId) { fetch(`/api/chat/sessions/${sessionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: newMode }), }).then(() => { window.dispatchEvent(new CustomEvent('session-updated')); }).catch(() => { /* silent */ }); } }, [sessionId]); const abortControllerRef = useRef(null); const handleWorkingDirectoryChange = useCallback((dir: string) => { setWorkingDirectory(dir); setPanelOpen(true); // Persist to database if (sessionId) { fetch(`/api/chat/sessions/${sessionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ working_directory: dir }), }).catch(() => { /* silent */ }); } }, [sessionId, setWorkingDirectory, setPanelOpen]); const initializedRef = useRef(false); useEffect(() => { if (initialMessages.length > 0 && !initializedRef.current) { initializedRef.current = true; setMessages(initialMessages); } }, [initialMessages]); // Sync mode when session data loads useEffect(() => { if (initialMode) { setMode(initialMode); } }, [initialMode]); const stopStreaming = useCallback(() => { abortControllerRef.current?.abort(); abortControllerRef.current = null; }, []); const handlePermissionResponse = useCallback(async (decision: 'allow' | 'allow_session' | 'deny') => { if (!pendingPermission) return; const body: { permissionRequestId: string; decision: { behavior: 'allow'; updatedPermissions?: unknown[] } | { behavior: 'deny'; message?: string } } = { permissionRequestId: pendingPermission.permissionRequestId, decision: decision === 'deny' ? { behavior: 'deny', message: 'User denied permission' } : { behavior: 'allow', ...(decision === 'allow_session' && pendingPermission.suggestions ? { updatedPermissions: pendingPermission.suggestions } : {}), }, }; setPermissionResolved(decision === 'deny' ? 'deny' : 'allow'); setPendingApprovalSessionId(''); try { await fetch('/api/chat/permission', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } catch { // Best effort - the stream will handle timeout } // Clear permission state after a short delay so user sees the feedback setTimeout(() => { setPendingPermission(null); setPermissionResolved(null); }, 1000); }, [pendingPermission, setPendingApprovalSessionId]); const sendMessage = useCallback( async (content: string) => { if (isStreaming) return; // Optimistic: add user message to UI immediately const userMessage: Message = { id: 'temp-' + Date.now(), session_id: sessionId, role: 'user', content, created_at: new Date().toISOString(), token_usage: null, }; setMessages((prev) => [...prev, userMessage]); setIsStreaming(true); setStreamingSessionId(sessionId); setStreamingContent(''); setToolUses([]); setToolResults([]); setStatusText(undefined); const controller = new AbortController(); abortControllerRef.current = controller; let accumulated = ''; try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, content, mode, model: currentModel }), signal: controller.signal, }); if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Failed to send message'); } const reader = response.body?.getReader(); if (!reader) throw new Error('No response stream'); const decoder = new TextDecoder(); let tokenUsage: TokenUsage | null = null; let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); // Keep the last potentially incomplete line in the buffer buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; try { const event: SSEEvent = JSON.parse(line.slice(6)); switch (event.type) { case 'text': { accumulated += event.data; setStreamingContent(accumulated); break; } case 'tool_use': { try { const toolData = JSON.parse(event.data); // Clear streaming output for new tool setStreamingToolOutput(''); setToolUses((prev) => { // Avoid duplicates if (prev.some((t) => t.id === toolData.id)) return prev; return [...prev, { id: toolData.id, name: toolData.name, input: toolData.input, }]; }); } catch { // skip malformed tool_use data } break; } case 'tool_result': { try { const resultData = JSON.parse(event.data); setStreamingToolOutput(''); setToolResults((prev) => [...prev, { tool_use_id: resultData.tool_use_id, content: resultData.content, }]); } catch { // skip malformed tool_result data } break; } case 'tool_output': { // Check if this is a progress heartbeat or real stderr output try { const parsed = JSON.parse(event.data); if (parsed._progress) { // SDK tool_progress event — update status with elapsed time setStatusText(`Running ${parsed.tool_name}... (${Math.round(parsed.elapsed_time_seconds)}s)`); break; } } catch { // Not JSON — it's raw stderr output, fall through } // Real-time stderr output from tool execution setStreamingToolOutput((prev) => { const next = prev + (prev ? '\n' : '') + event.data; return next.length > 5000 ? next.slice(-5000) : next; }); break; } case 'status': { try { const statusData = JSON.parse(event.data); if (statusData.session_id) { // Init event — show briefly then clear so tool status can take over setStatusText(`Connected (${statusData.model || 'claude'})`); setTimeout(() => setStatusText(undefined), 2000); } else if (statusData.notification) { // Notification from SDK hooks — show as progress setStatusText(statusData.message || statusData.title || undefined); } else { setStatusText(typeof event.data === 'string' ? event.data : undefined); } } catch { setStatusText(event.data || undefined); } break; } case 'result': { try { const resultData = JSON.parse(event.data); if (resultData.usage) { tokenUsage = resultData.usage; } } catch { // skip } setStatusText(undefined); break; } case 'permission_request': { try { const permData: PermissionRequestEvent = JSON.parse(event.data); setPendingPermission(permData); setPermissionResolved(null); setPendingApprovalSessionId(sessionId); } catch { // skip malformed permission_request data } break; } case 'error': { accumulated += '\n\n**Error:** ' + event.data; setStreamingContent(accumulated); break; } case 'done': { // Stream complete break; } } } catch { // skip malformed SSE lines } } } // Add the assistant message to the list if (accumulated.trim()) { const assistantMessage: Message = { id: 'temp-assistant-' + Date.now(), session_id: sessionId, role: 'assistant', content: accumulated.trim(), created_at: new Date().toISOString(), token_usage: tokenUsage ? JSON.stringify(tokenUsage) : null, }; setMessages((prev) => [...prev, assistantMessage]); } } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { // User stopped generation - still add partial content if (accumulated.trim()) { const partialMessage: Message = { id: 'temp-assistant-' + Date.now(), session_id: sessionId, role: 'assistant', content: accumulated.trim() + '\n\n*(generation stopped)*', created_at: new Date().toISOString(), token_usage: null, }; setMessages((prev) => [...prev, partialMessage]); } } else { const errMsg = error instanceof Error ? error.message : 'Unknown error'; const errorMessage: Message = { id: 'temp-error-' + Date.now(), session_id: sessionId, role: 'assistant', content: `**Error:** ${errMsg}`, created_at: new Date().toISOString(), token_usage: null, }; setMessages((prev) => [...prev, errorMessage]); } } finally { setIsStreaming(false); setStreamingSessionId(''); setStreamingContent(''); setToolUses([]); setToolResults([]); setStreamingToolOutput(''); setStatusText(undefined); setPendingPermission(null); setPermissionResolved(null); setPendingApprovalSessionId(''); abortControllerRef.current = null; } }, [sessionId, isStreaming, setStreamingSessionId, setPendingApprovalSessionId, mode, currentModel] ); const handleCommand = useCallback((command: string) => { switch (command) { case '/help': { const helpMessage: Message = { id: 'cmd-' + Date.now(), session_id: sessionId, role: 'assistant', content: `## Available Commands\n\n- **/help** - Show this help message\n- **/clear** - Clear conversation history\n- **/compact** - Compress conversation context\n- **/cost** - Show token usage statistics\n- **/doctor** - Check system health\n- **/init** - Initialize CLAUDE.md\n- **/review** - Start code review\n- **/terminal-setup** - Configure terminal\n\n**Tips:**\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`, created_at: new Date().toISOString(), token_usage: null, }; setMessages(prev => [...prev, helpMessage]); break; } case '/clear': setMessages([]); break; case '/cost': { const costMessage: Message = { id: 'cmd-' + Date.now(), session_id: sessionId, role: 'assistant', content: `## Token Usage\n\nToken usage tracking is available after sending messages. Check the token count displayed at the bottom of each assistant response.`, created_at: new Date().toISOString(), token_usage: null, }; setMessages(prev => [...prev, costMessage]); break; } default: sendMessage(command); } }, [sessionId, sendMessage]); return (
); }