import { html, useState, useEffect, useRef, useCallback } from '../lib/preact.js'; import { getStatusMeta, getUserMessageBg } from '../utils/status.js'; import { formatDuration, formatTime } from '../utils/formatting.js'; import { MessageBubble, filterDisplayMessages } from './MessageBubble.js'; export function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) { const [inputValue, setInputValue] = useState(''); const [sending, setSending] = useState(false); const [inputFocused, setInputFocused] = useState(false); const [closing, setClosing] = useState(false); const inputRef = useRef(null); const wasAtBottomRef = useRef(true); const prevConversationLenRef = useRef(0); const chatContainerRef = useRef(null); const conversation = session ? (conversations[session.session_id] || []) : []; // Reset state when session changes useEffect(() => { setClosing(false); prevConversationLenRef.current = 0; }, [session?.session_id]); // Animated close handler const handleClose = useCallback(() => { setClosing(true); setTimeout(() => { setClosing(false); onClose(); }, 200); }, [onClose]); // Track scroll position useEffect(() => { const container = chatContainerRef.current; if (!container) return; const handleScroll = () => { const threshold = 50; wasAtBottomRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < threshold; }; container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); }, []); // Only scroll to bottom on NEW messages, and only if user was already at bottom useEffect(() => { const container = chatContainerRef.current; if (!container || !conversation) return; const hasNewMessages = conversation.length > prevConversationLenRef.current; prevConversationLenRef.current = conversation.length; if (hasNewMessages && wasAtBottomRef.current) { container.scrollTop = container.scrollHeight; } }, [conversation]); // Focus input when modal opens useEffect(() => { if (inputRef.current) { inputRef.current.focus(); } }, [session?.session_id]); // Lock body scroll when modal is open useEffect(() => { if (!session) return; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, [!!session]); // Handle keyboard events useEffect(() => { if (!session) return; const handleKeyDown = (e) => { if (e.key === 'Escape') handleClose(); }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [!!session, handleClose]); if (!session) return null; const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0; const optionCount = hasPendingQuestions ? session.pending_questions[0]?.options?.length || 0 : 0; const status = getStatusMeta(session.status); const agent = session.agent === 'codex' ? 'codex' : 'claude'; const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude'; const handleInputKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const handleSend = async () => { const text = inputValue.trim(); if (!text || sending) return; setSending(true); try { if (onSendMessage) { await onSendMessage(session.session_id, text, true, optionCount); } setInputValue(''); if (onRefreshConversation) { await onRefreshConversation(session.session_id, session.project_dir, agent); } } catch (err) { console.error('Failed to send message:', err); } finally { setSending(false); } }; const displayMessages = filterDisplayMessages(conversation); return html`
No conversation messages
`}