'use client'; import { useState, useCallback, useEffect, useRef } from 'react'; import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types'; import { MessageList } from './MessageList'; import { MessageInput } from './MessageInput'; import { usePanel } from '@/hooks/usePanel'; import { BatchExecutionDashboard, BatchContextSync } from './batch-image-gen'; import { setLastGeneratedImages, transferPendingToMessage } from '@/lib/image-ref-store'; import { startStream, stopStream, subscribe, getSnapshot, respondToPermission, clearSnapshot, } from '@/lib/stream-session-manager'; interface ChatViewProps { sessionId: string; initialMessages?: Message[]; initialHasMore?: boolean; modelName?: string; initialMode?: string; providerId?: string; } export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId }: ChatViewProps) { const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel(); const [messages, setMessages] = useState(initialMessages); const [hasMore, setHasMore] = useState(initialHasMore); const [loadingMore, setLoadingMore] = useState(false); const loadingMoreRef = useRef(false); const [mode, setMode] = useState(initialMode || 'code'); const [currentModel, setCurrentModel] = useState(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet'); const [currentProviderId, setCurrentProviderId] = useState(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || ''); // Stream snapshot from the manager — drives all streaming UI const [streamSnapshot, setStreamSnapshot] = useState( () => getSnapshot(sessionId) ); // Derive rendering state from snapshot (backward-compatible with MessageList props) const isStreaming = streamSnapshot?.phase === 'active'; const streamingContent = streamSnapshot?.streamingContent ?? ''; const toolUses = streamSnapshot?.toolUses ?? []; const toolResults = streamSnapshot?.toolResults ?? []; const streamingToolOutput = streamSnapshot?.streamingToolOutput ?? ''; const statusText = streamSnapshot?.statusText; const pendingPermission = streamSnapshot?.pendingPermission ?? null; const permissionResolved = streamSnapshot?.permissionResolved ?? null; // Pending image generation notices — flushed into the next user message so the LLM knows about generated images const pendingImageNoticesRef = useRef([]); // Ref for sendMessage to allow self-referencing in timeout auto-retry const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise>(undefined); 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 */ }); // Try to switch SDK permission mode in real-time (works if streaming) fetch('/api/chat/mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId, mode: newMode }), }).catch(() => { /* silent — will apply on next message */ }); } }, [sessionId]); const handleProviderModelChange = useCallback((newProviderId: string, model: string) => { setCurrentProviderId(newProviderId); setCurrentModel(model); }, []); // Subscribe to stream-session-manager for this session. // On unmount we only unsubscribe — we do NOT abort the stream. useEffect(() => { // Restore snapshot if stream is already active (e.g., user switched away and back) const existing = getSnapshot(sessionId); if (existing) { setStreamSnapshot(existing); if (existing.phase === 'active') { setStreamingSessionId(sessionId); } if (existing.pendingPermission && !existing.permissionResolved) { setPendingApprovalSessionId(sessionId); } } else { setStreamSnapshot(null); } const unsubscribe = subscribe(sessionId, (event) => { setStreamSnapshot(event.snapshot); // Sync panel state if (event.type === 'phase-changed') { if (event.snapshot.phase === 'active') { setStreamingSessionId(sessionId); } else { setStreamingSessionId(''); setPendingApprovalSessionId(''); } } if (event.type === 'permission-request') { setPendingApprovalSessionId(sessionId); } if (event.type === 'completed') { setStreamingSessionId(''); setPendingApprovalSessionId(''); // Append the final assistant message to the messages list const finalContent = event.snapshot.finalMessageContent; if (finalContent) { const assistantMessage: Message = { id: 'temp-assistant-' + Date.now(), session_id: sessionId, role: 'assistant', content: finalContent, created_at: new Date().toISOString(), token_usage: event.snapshot.tokenUsage ? JSON.stringify(event.snapshot.tokenUsage) : null, }; // Transfer pending reference images to this message ID transferPendingToMessage(assistantMessage.id); setMessages((prev) => [...prev, assistantMessage]); } // Clear the snapshot from the manager since we've consumed it clearSnapshot(sessionId); } }); return () => { unsubscribe(); // Do NOT abort — stream continues in the manager }; }, [sessionId, setStreamingSessionId, setPendingApprovalSessionId]); 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]); // Sync hasMore when initial data loads useEffect(() => { setHasMore(initialHasMore); }, [initialHasMore]); const loadEarlierMessages = useCallback(async () => { // Use ref as atomic lock to prevent double-fetch from rapid clicks if (loadingMoreRef.current || !hasMore || messages.length === 0) return; loadingMoreRef.current = true; setLoadingMore(true); try { // Use _rowid of the earliest message as cursor const earliest = messages[0]; const earliestRowId = (earliest as Message & { _rowid?: number })._rowid; if (!earliestRowId) return; const res = await fetch(`/api/chat/sessions/${sessionId}/messages?limit=100&before=${earliestRowId}`); if (!res.ok) return; const data: MessagesResponse = await res.json(); setHasMore(data.hasMore ?? false); if (data.messages.length > 0) { setMessages(prev => [...data.messages, ...prev]); } } finally { loadingMoreRef.current = false; setLoadingMore(false); } }, [sessionId, messages, hasMore]); // Stop streaming — delegates to manager const stopStreaming = useCallback(() => { stopStream(sessionId); }, [sessionId]); // Permission response — delegates to manager const handlePermissionResponse = useCallback( async (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record) => { setPendingApprovalSessionId(''); await respondToPermission(sessionId, decision, updatedInput); }, [sessionId, setPendingApprovalSessionId] ); // Send message — delegates stream management to the manager const sendMessage = useCallback( async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => { if (isStreaming) return; // Use displayOverride for UI if provided (e.g. image-gen skill injection hides the skill prompt) const displayUserContent = displayOverride || content; // Build display content: embed file metadata as HTML comment for MessageItem to parse let displayContent = displayUserContent; if (files && files.length > 0) { const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size })); displayContent = `${displayUserContent}`; } // Optimistic: add user message to UI immediately const userMessage: Message = { id: 'temp-' + Date.now(), session_id: sessionId, role: 'user', content: displayContent, created_at: new Date().toISOString(), token_usage: null, }; setMessages((prev) => [...prev, userMessage]); // Flush pending image notices const notices = pendingImageNoticesRef.current.length > 0 ? [...pendingImageNoticesRef.current] : undefined; if (notices) { pendingImageNoticesRef.current = []; } // Delegate to stream session manager startStream({ sessionId, content, mode, model: currentModel, providerId: currentProviderId, files, systemPromptAppend, pendingImageNotices: notices, onModeChanged: (sdkMode) => { const uiMode = sdkMode === 'plan' ? 'plan' : 'code'; handleModeChange(uiMode); }, sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => { sendMessageRef.current?.(retryContent, retryFiles); }, }); }, [sessionId, isStreaming, mode, currentModel, currentProviderId, handleModeChange] ); // Keep sendMessageRef in sync so timeout auto-retry can call it sendMessageRef.current = sendMessage; 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### Instant Commands\n- **/help** — Show this help message\n- **/clear** — Clear conversation history\n- **/cost** — Show token usage statistics\n\n### Prompt Commands (shown as badge, add context then send)\n- **/compact** — Compress conversation context\n- **/doctor** — Diagnose project health\n- **/init** — Initialize CLAUDE.md for project\n- **/review** — Review code quality\n- **/terminal-setup** — Configure terminal settings\n- **/memory** — Edit project memory file\n\n### Custom Skills\nSkills from \`~/.claude/commands/\` and project \`.claude/commands/\` are also available via \`/\`.\n\n**Tips:**\n- Type \`/\` to browse commands and skills\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([]); // Also clear database messages and reset SDK session if (sessionId) { fetch(`/api/chat/sessions/${sessionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clear_messages: true }), }).catch(() => { /* silent */ }); } break; case '/cost': { // Aggregate token usage from all messages in this session let totalInput = 0; let totalOutput = 0; let totalCacheRead = 0; let totalCacheCreation = 0; let totalCost = 0; let turnCount = 0; for (const msg of messages) { if (msg.token_usage) { try { const usage = typeof msg.token_usage === 'string' ? JSON.parse(msg.token_usage) : msg.token_usage; totalInput += usage.input_tokens || 0; totalOutput += usage.output_tokens || 0; totalCacheRead += usage.cache_read_input_tokens || 0; totalCacheCreation += usage.cache_creation_input_tokens || 0; if (usage.cost_usd) totalCost += usage.cost_usd; turnCount++; } catch { /* skip */ } } } const totalTokens = totalInput + totalOutput; let content: string; if (turnCount === 0) { content = `## Token Usage\n\nNo token usage data yet. Send a message first.`; } else { content = `## Token Usage\n\n| Metric | Count |\n|--------|-------|\n| Input tokens | ${totalInput.toLocaleString()} |\n| Output tokens | ${totalOutput.toLocaleString()} |\n| Cache read | ${totalCacheRead.toLocaleString()} |\n| Cache creation | ${totalCacheCreation.toLocaleString()} |\n| **Total tokens** | **${totalTokens.toLocaleString()}** |\n| Turns | ${turnCount} |${totalCost > 0 ? `\n| **Estimated cost** | **$${totalCost.toFixed(4)}** |` : ''}`; } const costMessage: Message = { id: 'cmd-' + Date.now(), session_id: sessionId, role: 'assistant', content, created_at: new Date().toISOString(), token_usage: null, }; setMessages(prev => [...prev, costMessage]); break; } default: // This shouldn't be reached since non-immediate commands are handled via badge sendMessage(command); } }, [sessionId, sendMessage]); // Listen for image generation completion — persist notice to DB and queue for next user message. // The notice is NOT sent as a separate LLM turn (avoids permission popups). // Instead it's flushed into the next user message via pendingImageNoticesRef. // MessageItem hides messages matching this prefix so the user doesn't see them. useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail) return; const paths = (detail.images || []) .map((img: { localPath?: string }) => img.localPath) .filter(Boolean); const pathInfo = paths.length > 0 ? `\nGenerated image file paths:\n${paths.map((p: string) => `- ${p}`).join('\n')}` : ''; const notice = `[Image generation completed]\n- Prompt: "${detail.prompt}"\n- Aspect ratio: ${detail.aspectRatio}\n- Resolution: ${detail.resolution}${pathInfo}`; // Store generated image paths so subsequent edits can use them as reference if (paths.length > 0) { setLastGeneratedImages(paths); } // Queue for next user message so the LLM gets the context pendingImageNoticesRef.current.push(notice); // Also persist to DB for history reload const dbNotice = `[__IMAGE_GEN_NOTICE__ prompt: "${detail.prompt}", aspect ratio: ${detail.aspectRatio}, resolution: ${detail.resolution}${paths.length > 0 ? `, file path: ${paths.join(', ')}` : ''}]`; fetch('/api/chat/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, role: 'user', content: dbNotice }), }).catch(() => {}); }; window.addEventListener('image-gen-completed', handler); return () => window.removeEventListener('image-gen-completed', handler); }, [sessionId]); return (
{/* Batch image generation panels — shown above the input area */}
); }