import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { ChevronUpIcon, Loader2Icon } from "lucide-react"; import { useCallback, useEffect, useRef } from "react"; import { Conversation, ConversationContent, } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; import { useI18n } from "@/core/i18n/hooks"; import { extractContentFromMessage, extractPresentFilesFromMessage, extractTextFromMessage, groupMessages, hasContent, hasPresentFiles, hasReasoning, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; import { cn } from "@/lib/utils"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { StreamingIndicator } from "../streaming-indicator"; import { MarkdownContent } from "./markdown-content"; import { MessageGroup } from "./message-group"; import { MessageListItem } from "./message-list-item"; import { MessageListSkeleton } from "./skeleton"; import { SubtaskCard } from "./subtask-card"; export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160; export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80; const LOAD_MORE_HISTORY_THROTTLE_MS = 1200; function LoadMoreHistoryIndicator({ isLoading, hasMore, loadMore, }: { isLoading?: boolean; hasMore?: boolean; loadMore?: () => void; }) { const { t } = useI18n(); const sentinelRef = useRef(null); const timeoutRef = useRef | null>(null); const lastLoadRef = useRef(0); const throttledLoadMore = useCallback(() => { if (!hasMore || isLoading) { return; } const now = Date.now(); const remaining = LOAD_MORE_HISTORY_THROTTLE_MS - (now - lastLoadRef.current); if (remaining <= 0) { lastLoadRef.current = now; loadMore?.(); return; } if (timeoutRef.current) { return; } timeoutRef.current = setTimeout(() => { timeoutRef.current = null; if (!hasMore || isLoading) { return; } lastLoadRef.current = Date.now(); loadMore?.(); }, remaining); }, [hasMore, isLoading, loadMore]); useEffect(() => { const element = sentinelRef.current; if (!element || !hasMore) { return; } const observer = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting) { throttledLoadMore(); } }, { rootMargin: "120px 0px 0px 0px", }, ); observer.observe(element); return () => { observer.disconnect(); }; }, [hasMore, throttledLoadMore]); useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); if (!hasMore && !isLoading) { return null; } return (
); } export function MessageList({ className, threadId, thread, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, hasMoreHistory, loadMoreHistory, isHistoryLoading, }: { className?: string; threadId: string; thread: BaseStream; paddingBottom?: number; hasMoreHistory?: boolean; loadMoreHistory?: () => void; isHistoryLoading?: boolean; }) { const { t } = useI18n(); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const updateSubtask = useUpdateSubtask(); const messages = thread.messages; if (thread.isThreadLoading && messages.length === 0) { return ; } return ( {groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { return group.messages.map((msg) => { return ( ); }); } else if (group.type === "assistant:clarification") { const message = group.messages[0]; if (message && hasContent(message)) { return ( ); } return null; } else if (group.type === "assistant:present-files") { const files: string[] = []; for (const message of group.messages) { if (hasPresentFiles(message)) { const presentFiles = extractPresentFilesFromMessage(message); files.push(...presentFiles); } } return (
{group.messages[0] && hasContent(group.messages[0]) && ( )}
); } else if (group.type === "assistant:subagent") { const tasks = new Set(); for (const message of group.messages) { if (message.type === "ai") { for (const toolCall of message.tool_calls ?? []) { if (toolCall.name === "task") { const task: Subtask = { id: toolCall.id!, subagent_type: toolCall.args.subagent_type, description: toolCall.args.description, prompt: toolCall.args.prompt, status: "in_progress", }; updateSubtask(task); tasks.add(task); } } } else if (message.type === "tool") { const taskId = message.tool_call_id; if (taskId) { const result = extractTextFromMessage(message); if (result.startsWith("Task Succeeded. Result:")) { updateSubtask({ id: taskId, status: "completed", result: result .split("Task Succeeded. Result:")[1] ?.trim(), }); } else if (result.startsWith("Task failed.")) { updateSubtask({ id: taskId, status: "failed", error: result.split("Task failed.")[1]?.trim(), }); } else if (result.startsWith("Task timed out")) { updateSubtask({ id: taskId, status: "failed", error: result, }); } else { updateSubtask({ id: taskId, status: "in_progress", }); } } } } const results: React.ReactNode[] = []; for (const message of group.messages.filter( (message) => message.type === "ai", )) { if (hasReasoning(message)) { results.push( , ); } results.push(
{t.subtasks.executing(tasks.size)}
, ); const taskIds = message.tool_calls ?.filter((toolCall) => toolCall.name === "task") .map((toolCall) => toolCall.id); for (const taskId of taskIds ?? []) { results.push( , ); } } return (
{results}
); } return ( ); })} {thread.isLoading && }
); }