import { useEffect, useMemo, useRef, useState, useCallback } from "react" import { Loader2, Code, FileText, GitPullRequest, Brain, ChevronDown, Sparkles, } from "lucide-react" import { useVirtualizer } from "@tanstack/react-virtual" import { useQueryClient } from "@tanstack/react-query" import { useMessagesQuery } from "@/hooks/useAiChatQuery" import { useStreamingStore } from "@/store/streaming" import type { StreamPart } from "@/store/streaming" import { ChatMessageBubble } from "./ChatMessageBubble" import { useChatPage } from "./ChatPageContext" import { getModelIcon } from "@/lib/icons/modelIcons" import { IrRenderer } from "@/lib/ir/renderer" import { Shimmer } from "@/components/ai-elements/shimmer" import { Reasoning, ReasoningTrigger, ReasoningContent, } from "@/components/ai-elements/reasoning" import { ToolCallBlock } from "@/components/chat/ToolCallBlock" import { useCodePreview } from "@/components/chat/CodePreviewContext" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@/components/ui/empty" import { t } from "@/i18n/T" interface ChatMessageListProps { conversationId: string | null setIsStreaming: (value: boolean) => void } const OVERSCAN = 3 const ESTIMATED_SIZE = 200 const PROSE_CLASS = "prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1" const PROMPT_SUGGESTIONS = [ { icon: Code, text: t("chat.message_list.explain_code") }, { icon: FileText, text: t("chat.message_list.summarize_doc") }, { icon: GitPullRequest, text: t("chat.message_list.review_pr") }, { icon: Brain, text: t("chat.message_list.brainstorm") }, ] export function ChatMessageList({ conversationId, setIsStreaming, }: ChatMessageListProps) { const { data, isLoading } = useMessagesQuery(conversationId || "") const scrollRef = useRef(null) const messages = useMemo(() => data?.messages ?? [], [data?.messages]) const stream = useStreamingStore((s) => conversationId ? s.streams[conversationId] : undefined ) const isStreaming = stream && !stream.isDone const queryClient = useQueryClient() const codePreview = useCodePreview() const [isAtBottom, setIsAtBottom] = useState(true) const [userScrolledUp, setUserScrolledUp] = useState(false) const [activeUserAnchor, setActiveUserAnchor] = useState(0) const checkAtBottom = useCallback(() => { const el = scrollRef.current if (!el) return const distance = el.scrollHeight - el.scrollTop - el.clientHeight const atBottom = distance < 200 setIsAtBottom(atBottom) if (atBottom) setUserScrolledUp(false) }, []) const userAnchors = useMemo(() => { return messages .map((message, index) => ({ message, index })) .filter(({ message }) => message.role === "user") .map(({ index }) => index) }, [messages]) const showUserTimeline = userAnchors.length > 0 && !codePreview?.activeCode const hasStreamingBubble = !!stream?.parts && stream.parts.length > 0 const streamContentLength = stream?.parts.reduce((sum, part) => sum + part.content.length, 0) ?? 0 // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: messages.length, getScrollElement: () => scrollRef.current, estimateSize: () => ESTIMATED_SIZE, overscan: OVERSCAN, }) const updateActiveUserAnchor = useCallback(() => { if (messages.length === 0 || userAnchors.length === 0) { setActiveUserAnchor(0) return } const visibleItems = virtualizer.getVirtualItems() if (visibleItems.length === 0) return const firstVisible = visibleItems[0]?.index ?? 0 let closestAnchor = 0 for (let i = 0; i < userAnchors.length; i++) { if (userAnchors[i] <= firstVisible) { closestAnchor = i } else { break } } setActiveUserAnchor(closestAnchor) }, [messages.length, userAnchors, virtualizer]) const handleScroll = useCallback(() => { const el = scrollRef.current if (!el) return const distance = el.scrollHeight - el.scrollTop - el.clientHeight const atBottom = distance < 200 setIsAtBottom(atBottom) if (!atBottom) setUserScrolledUp(true) updateActiveUserAnchor() }, [updateActiveUserAnchor]) useEffect(() => { updateActiveUserAnchor() }, [updateActiveUserAnchor, streamContentLength]) useEffect(() => { if (isAtBottom && scrollRef.current) { requestAnimationFrame(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } }) } }, [messages.length, streamContentLength, isAtBottom]) useEffect(() => { if (conversationId && messages.length > 0) { checkAtBottom() } }, [conversationId, messages.length, checkAtBottom]) useEffect(() => { if (scrollRef.current && messages.length > 0) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } }, [isLoading, messages.length]) if (!conversationId || (messages.length === 0 && !hasStreamingBubble)) { return (
{t("chat.message_list.welcome_title")} {t("chat.message_list.welcome_desc")}
{PROMPT_SUGGESTIONS.map((s, i) => ( ))}
) } if (isLoading) { return (
) } return (
{isStreaming && userScrolledUp && (
{ if (scrollRef.current) { scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth", }) setUserScrolledUp(false) setIsAtBottom(true) } }} >
{t("chat.message_list.new_response")}
)}
{showUserTimeline && (
{userAnchors.map((messageIndex, anchorIndex) => { const isActive = anchorIndex === activeUserAnchor return ( ) })}
)}
{virtualizer.getVirtualItems().map((virtualItem) => { const message = messages[virtualItem.index] if (!message) return null return (
{ queryClient.invalidateQueries({ queryKey: ["ai-messages", conversationId], }) queryClient.invalidateQueries({ queryKey: ["ai-conversations", conversationId], }) }} setIsStreaming={setIsStreaming} />
) })}
{hasStreamingBubble && (
)}
) } function StreamingBubble({ parts, isDone, conversationId, }: { parts: StreamPart[] isDone: boolean conversationId: string }) { const { selectedModel } = useChatPage() const [displayParts, setDisplayParts] = useState([]) const [displayDone, setDisplayDone] = useState(false) const contentRef = useRef(null) const latestRef = useRef({ parts, isDone }) const rafRef = useRef(0) useEffect(() => { latestRef.current = { parts, isDone } }) const hasParts = parts.length > 0 useEffect(() => { if (!hasParts) return const sync = () => { const { parts: p, isDone: d } = latestRef.current setDisplayParts([...p]) setDisplayDone(d) if (!d) { rafRef.current = requestAnimationFrame(sync) } } rafRef.current = requestAnimationFrame(sync) return () => cancelAnimationFrame(rafRef.current) }, [hasParts]) useEffect(() => { if (contentRef.current) { contentRef.current.style.height = "auto" requestAnimationFrame(() => { if (contentRef.current) { contentRef.current.style.height = "" } }) } }, [displayParts.length]) const activeThinkingIdx = !displayDone && displayParts[displayParts.length - 1]?.type === "thinking" ? displayParts.length - 1 : -1 return (
{selectedModel?.model_name || "Assistant"} {!displayDone && ( {t("chat.message_list.responding")} )}
{displayParts.map((part, i) => { if (part.type === "thinking") { return ( ) } if (part.type === "tool_call") { if (part.toolName !== "call_sub_agent") return null return ( ) } if (part.type === "tool_result") { if (part.toolName !== "call_sub_agent") return null return ( ) } const isLast = i === displayParts.length - 1 return (
{isLast && !displayDone && }
) })}
) } function StreamingReasoningBlock({ content, isActivelyThinking, }: { content: string isActivelyThinking: boolean }) { const [manualOpen, setManualOpen] = useState(false) const [autoCollapsed, setAutoCollapsed] = useState(false) const wasActivelyThinkingRef = useRef(isActivelyThinking) useEffect(() => { const wasActivelyThinking = wasActivelyThinkingRef.current if (isActivelyThinking && !wasActivelyThinking) { setAutoCollapsed(false) setManualOpen(false) } if (!isActivelyThinking && wasActivelyThinking) { setAutoCollapsed(false) setManualOpen(false) } wasActivelyThinkingRef.current = isActivelyThinking }, [isActivelyThinking]) const isOpen = isActivelyThinking ? !autoCollapsed : manualOpen const handleOpenChange = useCallback( (nextOpen: boolean) => { if (isActivelyThinking) { setAutoCollapsed(!nextOpen) return } setManualOpen(nextOpen) }, [isActivelyThinking] ) return ( {content} ) } function StreamingCursor() { return ( ) } const AVATAR_COLORS = [ "#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e", "#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#06b6d4", "#3b82f6", "#2563eb", "#7c3aed", "#c026d3", ] function hashColor(str: string): string { let hash = 0 for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash) } return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] } function StreamingModelAvatar({ modelName, size = 28, }: { modelName?: string | null size?: number }) { if (!modelName) { return (
) } const Icon = getModelIcon(modelName) if (Icon) { return (
) } return (
{modelName[0]?.toUpperCase() || "?"}
) }