import { useState, useRef, useEffect, useCallback, useLayoutEffect, } from "react"; import { Send, Square, Plus, Lightbulb, LightbulbOff, ChevronDown, Settings, X, } from "lucide-react"; import { useLLM } from "../hooks/useLLM"; import { MessageBubble } from "./MessageBubble"; import { MODEL_CONFIG } from "../model-config"; import type { ThinkingMode } from "../hooks/LLMContext"; const TEXTAREA_MIN_HEIGHT = "7.5rem"; const THINKING_OPTIONS: { value: ThinkingMode; label: string }[] = [ { value: "enabled", label: "Reasoning On" }, { value: "disabled", label: "Reasoning Off" }, ]; export function ChatApp() { const { messages, isGenerating, tps, send, stop, status, clearChat, thinkingMode, setThinkingMode, systemPrompt, setSystemPrompt, } = useLLM(); const [showSettings, setShowSettings] = useState(false); const [input, setInput] = useState(""); const scrollRef = useRef(null); const textareaRef = useRef(null); const [showThinkingMenu, setShowThinkingMenu] = useState(false); const thinkingMenuRef = useRef(null); const [thinkingSeconds, setThinkingSeconds] = useState(0); const thinkingStartRef = useRef(null); const thinkingSecondsMapRef = useRef>(new Map()); const prevIsGeneratingRef = useRef(false); const messagesRef = useRef(messages); const thinkingSecondsRef = useRef(thinkingSeconds); messagesRef.current = messages; thinkingSecondsRef.current = thinkingSeconds; const isReady = status.state === "ready"; const hasMessages = messages.length > 0; const hasCompletedRef = useRef(false); useEffect(() => { if (hasMessages && !isGenerating) hasCompletedRef.current = true; if (!hasMessages) hasCompletedRef.current = false; }, [hasMessages, isGenerating]); const showNewChat = isReady && hasMessages && !isGenerating && hasCompletedRef.current; const prevMsgCountRef = useRef(0); const lastUserRef = useRef(null); const bottomSpacerRef = useRef(null); const userHasScrolledRef = useRef(false); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( thinkingMenuRef.current && !thinkingMenuRef.current.contains(event.target as Node) ) { setShowThinkingMenu(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); useEffect(() => { if (prevIsGeneratingRef.current && !isGenerating) { const lastMessage = messagesRef.current.at(-1); if ( lastMessage?.role === "assistant" && lastMessage.reasoning && thinkingSecondsRef.current > 0 ) { thinkingSecondsMapRef.current.set( lastMessage.id, thinkingSecondsRef.current, ); } } prevIsGeneratingRef.current = isGenerating; }, [isGenerating]); useEffect(() => { if (!isGenerating) { thinkingStartRef.current = null; return; } thinkingStartRef.current = Date.now(); setThinkingSeconds(0); const interval = setInterval(() => { if (thinkingStartRef.current) { setThinkingSeconds( Math.round((Date.now() - thinkingStartRef.current) / 1000), ); } }, 500); return () => clearInterval(interval); }, [isGenerating]); const lastAssistant = messages.at(-1); useEffect(() => { if ( isGenerating && lastAssistant?.role === "assistant" && lastAssistant.content ) { thinkingStartRef.current = null; } }, [isGenerating, lastAssistant?.role, lastAssistant?.content]); const getContainerPadTop = useCallback(() => { const container = scrollRef.current; if (!container) return 0; return parseFloat(getComputedStyle(container).paddingTop) || 0; }, []); const recalcSpacer = useCallback(() => { const container = scrollRef.current; const userElement = lastUserRef.current; const spacer = bottomSpacerRef.current; if (!container || !userElement || !spacer) return; // Compute the user element's offset from the top of the scroll content // (immune to browser scrollTop clamping). const userOffsetInContent = userElement.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop; const padTop = getContainerPadTop(); const padBottom = parseFloat(getComputedStyle(container).paddingBottom) || 0; const usableHeight = container.clientHeight - padTop - padBottom; // Distance from user element top to the spacer top (everything between them). const contentBelowUser = spacer.getBoundingClientRect().top - userElement.getBoundingClientRect().top; spacer.style.height = `${Math.max(0, usableHeight - contentBelowUser)}px`; // Only re-anchor if the user hasn't manually scrolled away. if (!userHasScrolledRef.current) { const desiredScrollTop = userOffsetInContent - padTop; if (Math.abs(container.scrollTop - desiredScrollTop) > 0.5) { container.scrollTo({ top: desiredScrollTop, behavior: "smooth" }); } } }, [getContainerPadTop]); useLayoutEffect(() => { recalcSpacer(); const isNewMessage = messages.length > prevMsgCountRef.current; prevMsgCountRef.current = messages.length; if (isNewMessage) { // Reset manual-scroll flag so the initial snap works for the new message. userHasScrolledRef.current = false; const container = scrollRef.current; const userElement = lastUserRef.current; if (!container || !userElement) return; const scrollTarget = container.scrollTop + (userElement.getBoundingClientRect().top - container.getBoundingClientRect().top) - getContainerPadTop(); container.scrollTo({ top: scrollTarget, behavior: "smooth" }); } }, [messages, isGenerating, recalcSpacer, getContainerPadTop]); useEffect(() => { window.addEventListener("resize", recalcSpacer); return () => window.removeEventListener("resize", recalcSpacer); }, [recalcSpacer]); // Detect user-initiated scrolls (wheel or touch) to unlock free scrolling // during generation. useEffect(() => { const container = scrollRef.current; if (!container) return; const markScrolled = () => { if (isGenerating) { userHasScrolledRef.current = true; } }; container.addEventListener("wheel", markScrolled, { passive: true }); container.addEventListener("touchmove", markScrolled, { passive: true }); return () => { container.removeEventListener("wheel", markScrolled); container.removeEventListener("touchmove", markScrolled); }; }, [isGenerating]); // Re-anchor the user message whenever the scroll container's own size changes useLayoutEffect(() => { const container = scrollRef.current; if (!container) return; let lastHeight = container.clientHeight; const observer = new ResizeObserver(() => { const h = container.clientHeight; if (h !== lastHeight) { lastHeight = h; recalcSpacer(); } }); observer.observe(container); return () => observer.disconnect(); }, [recalcSpacer]); const handleSubmit = useCallback( (event?: React.FormEvent) => { event?.preventDefault(); const text = input.trim(); if (!text || !isReady || isGenerating) return; setInput(""); if (textareaRef.current) { textareaRef.current.style.height = TEXTAREA_MIN_HEIGHT; } send(text); }, [input, isReady, isGenerating, send], ); const handleInputKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); handleSubmit(); } }, [handleSubmit], ); const lastUserIndex = messages.findLastIndex( (message) => message.role === "user", ); const renderInputArea = (showDisclaimer: boolean) => (