"use client" import { memo, useEffect, useMemo, useRef } from "react" import { useDbMessageDetail } from "@/hooks/use-db-message-detail" import { ContentPartsRenderer } from "./content-parts-renderer" import { adaptMessageTurns, type AdaptedMessage, type AdaptedContentPart, type MessageGroup, type UserImageDisplay, type UserResourceDisplay, groupAdaptedMessages, extractUserResourcesFromText, } from "@/lib/adapters/ai-elements-adapter" import { TurnStats } from "./turn-stats" import { LiveTurnStats } from "./live-turn-stats" import { UserResourceLinks } from "./user-resource-links" import { UserImageAttachments } from "./user-image-attachments" import { useSessionStats } from "@/contexts/session-stats-context" import { LiveMessageBlock } from "@/components/chat/live-message-block" import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay" import type { LiveMessage } from "@/contexts/acp-connections-context" import { MessageThread, MessageThreadContent, } from "@/components/ai-elements/message-thread" import { Message, MessageContent } from "@/components/ai-elements/message" import { Loader2 } from "lucide-react" import { useTranslations } from "next-intl" import { buildPlanKey, extractLatestPlanEntriesFromMessages, } from "@/lib/agent-plan" import type { ConnectionStatus } from "@/lib/types" interface MessageListViewProps { conversationId: number connStatus?: ConnectionStatus | null liveMessage?: LiveMessage | null pendingMessages?: AdaptedMessage[] onPendingClear?: () => void isActive?: boolean } interface ResolvedMessageGroup extends MessageGroup { parts: AdaptedContentPart[] resources: UserResourceDisplay[] images: UserImageDisplay[] } function fallbackExtractUserResources( group: MessageGroup, attachedResourcesText: string ): { parts: AdaptedContentPart[] resources: UserResourceDisplay[] images: UserImageDisplay[] } { if (group.role !== "user") { return { parts: group.parts, resources: group.userResources ?? [], images: group.userImages ?? [], } } const parsedResources: UserResourceDisplay[] = [] const parsedParts: AdaptedContentPart[] = [] for (const part of group.parts) { if (part.type !== "text") { parsedParts.push(part) continue } const extracted = extractUserResourcesFromText(part.text) if (extracted.resources.length > 0) { parsedResources.push(...extracted.resources) if (extracted.text.length > 0) { parsedParts.push({ type: "text", text: extracted.text }) } } else { parsedParts.push(part) } } const resources = [...(group.userResources ?? []), ...parsedResources] const dedupedResources: UserResourceDisplay[] = [] const seen = new Set() for (const resource of resources) { const key = `${resource.name}::${resource.uri}` if (seen.has(key)) continue seen.add(key) dedupedResources.push(resource) } if (parsedParts.length === 0 && dedupedResources.length > 0) { parsedParts.push({ type: "text", text: attachedResourcesText }) } return { parts: parsedParts, resources: dedupedResources, images: group.userImages ?? [], } } function resolveMessageGroup( group: MessageGroup, attachedResourcesText: string ): ResolvedMessageGroup { const resolved = fallbackExtractUserResources(group, attachedResourcesText) return { ...group, parts: resolved.parts, resources: resolved.resources, images: resolved.images, } } const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ group, }: { group: ResolvedMessageGroup }) { return (
{group.role === "user" && group.images.length > 0 ? ( ) : null} {group.role === "user" && group.resources.length > 0 ? ( ) : null} {group.role === "assistant" && ( )}
) }) const PendingMessageGroup = memo(function PendingMessageGroup({ group, }: { group: ResolvedMessageGroup }) { return (
{group.role === "user" && group.images.length > 0 ? ( ) : null} {group.role === "user" && group.resources.length > 0 ? ( ) : null}
) }) export function MessageListView({ conversationId, connStatus, liveMessage, pendingMessages, onPendingClear, isActive = true, }: MessageListViewProps) { const t = useTranslations("Folder.chat.messageList") const sharedT = useTranslations("Folder.chat.shared") const { detail, loading, error, refetch } = useDbMessageDetail(conversationId) const turnCount = detail?.turns.length ?? 0 // Refetch when agent turn completes (prompting → other status) const prevStatusRef = useRef(connStatus) useEffect(() => { const prev = prevStatusRef.current prevStatusRef.current = connStatus if (prev === "prompting" && connStatus && connStatus !== "prompting") { refetch() } }, [connStatus, refetch]) // Clear pending when detail gains new turns (new data fetched successfully) const prevTurnCountRef = useRef(turnCount) const prevConvIdRef = useRef(conversationId) useEffect(() => { if (prevConvIdRef.current !== conversationId) { // Conversation switched — reset baseline, don't clear prevConvIdRef.current = conversationId prevTurnCountRef.current = turnCount return } if (turnCount > prevTurnCountRef.current && onPendingClear) { onPendingClear() } prevTurnCountRef.current = turnCount }, [turnCount, onPendingClear, conversationId]) const { setSessionStats } = useSessionStats() const sessionStats = detail?.session_stats ?? null useEffect(() => { if (isActive) { setSessionStats(sessionStats) } }, [isActive, sessionStats, setSessionStats]) // Track whether the initial scroll has happened. // After that, disable resize-triggered scroll so tab switches // don't jump to the bottom. const shouldUseSmoothResize = !(isActive && !loading && detail) const messages = useMemo( () => detail ? adaptMessageTurns(detail.turns, { attachedResources: sharedT("attachedResources"), toolCallFailed: sharedT("toolCallFailed"), }) : [], [detail, sharedT] ) const groups = useMemo(() => groupAdaptedMessages(messages), [messages]) const historicalPlanEntries = useMemo( () => extractLatestPlanEntriesFromMessages(messages), [messages] ) const historicalPlanKey = useMemo( () => buildPlanKey(historicalPlanEntries), [historicalPlanEntries] ) const pendingGroups = useMemo( () => pendingMessages?.length ? groupAdaptedMessages(pendingMessages) : [], [pendingMessages] ) const resolvedGroups = useMemo( () => groups.map((group) => resolveMessageGroup(group, sharedT("attachedResources")) ), [groups, sharedT] ) const resolvedPendingGroups = useMemo( () => pendingGroups.map((group) => resolveMessageGroup(group, sharedT("attachedResources")) ), [pendingGroups, sharedT] ) const showLiveMessage = Boolean( liveMessage && (connStatus === "prompting" || (liveMessage.content.length > 0 && resolvedPendingGroups.length > 0)) ) const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}` if (loading && !detail) { return (
{t("loading")}
) } if (error) { return (

{t("error", { message: error })}

) } if (!detail) return null return (
{/* Messages */} {resolvedGroups.length === 0 && resolvedPendingGroups.length === 0 && !showLiveMessage ? (

{t("emptyConversation")}

) : ( <> {resolvedGroups.map((group) => ( ))} {resolvedPendingGroups.map((group) => ( ))} {resolvedPendingGroups.length > 0 && !showLiveMessage && (
)} {showLiveMessage && liveMessage && ( )} )}
{showLiveMessage && liveMessage && ( )}
) }