"use client"
import { memo, useCallback, useEffect, useMemo, useRef } from "react"
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
import { ContentPartsRenderer } from "./content-parts-renderer"
import {
adaptMessageTurns,
type MessageGroup,
type UserImageDisplay,
type UserResourceDisplay,
groupAdaptedMessages,
} 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 { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
import { MessageThread } 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, SessionStats } from "@/lib/types"
import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"
import { useStickToBottomContext } from "use-stick-to-bottom"
interface MessageListViewProps {
conversationId: number
connStatus?: ConnectionStatus | null
isActive?: boolean
sendSignal?: number
sessionStats?: SessionStats | null
detailLoading?: boolean
detailError?: string | null
hideEmptyState?: boolean
}
interface ResolvedMessageGroup extends MessageGroup {
resources: UserResourceDisplay[]
images: UserImageDisplay[]
}
type ThreadRenderItem =
| {
key: string
kind: "turn"
group: ResolvedMessageGroup
phase: "persisted" | "optimistic" | "streaming"
}
| {
key: string
kind: "typing"
}
const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
group,
dimmed = false,
}: {
group: ResolvedMessageGroup
dimmed?: boolean
}) {
return (
{group.role === "user" && group.images.length > 0 ? (
) : null}
{group.role === "user" && group.resources.length > 0 ? (
) : null}
{group.role === "assistant" && (
)}
)
})
const PendingTypingIndicator = memo(function PendingTypingIndicator() {
return (
)
})
const AutoScrollOnSend = memo(function AutoScrollOnSend({
signal,
}: {
signal: number
}) {
const { scrollToBottom } = useStickToBottomContext()
const lastSignalRef = useRef(signal)
useEffect(() => {
if (signal === lastSignalRef.current) return
lastSignalRef.current = signal
scrollToBottom()
const rafId = requestAnimationFrame(() => {
scrollToBottom()
})
return () => {
cancelAnimationFrame(rafId)
}
}, [scrollToBottom, signal])
return null
})
export function MessageListView({
conversationId,
connStatus,
isActive = true,
sendSignal = 0,
sessionStats = null,
detailLoading = false,
detailError = null,
hideEmptyState = false,
}: MessageListViewProps) {
const t = useTranslations("Folder.chat.messageList")
const sharedT = useTranslations("Folder.chat.shared")
const { getSession, getTimelineTurns } = useConversationRuntime()
const session = getSession(conversationId)
const liveMessage = session?.liveMessage ?? null
const timelineTurns = getTimelineTurns(conversationId)
const { setSessionStats } = useSessionStats()
useEffect(() => {
if (isActive) {
setSessionStats(sessionStats)
}
}, [isActive, sessionStats, setSessionStats])
const shouldUseSmoothResize = !(
isActive &&
!detailLoading &&
timelineTurns.length
)
const adapterText = useMemo(
() => ({
attachedResources: sharedT("attachedResources"),
toolCallFailed: sharedT("toolCallFailed"),
}),
[sharedT]
)
const sessionSyncState = session?.syncState ?? "idle"
const { threadItems, nonStreamingAdapted } = useMemo(() => {
const allTurns = timelineTurns.map((item) => item.turn)
const allAdapted = adaptMessageTurns(allTurns, adapterText)
// Collect non-streaming adapted messages for plan extraction
const nonStreaming = allAdapted.filter(
(_, index) => timelineTurns[index].phase !== "streaming"
)
// Group adapted messages per phase-chunk to prevent merging
// assistant turns across phase boundaries (e.g. persisted + streaming).
const items: ThreadRenderItem[] = []
let chunkStart = 0
while (chunkStart < allAdapted.length) {
const chunkPhase = timelineTurns[chunkStart].phase
let chunkEnd = chunkStart + 1
while (
chunkEnd < allAdapted.length &&
timelineTurns[chunkEnd].phase === chunkPhase
) {
chunkEnd++
}
const chunkAdapted = allAdapted.slice(chunkStart, chunkEnd)
const groups = groupAdaptedMessages(chunkAdapted)
for (let i = 0; i < groups.length; i++) {
const group = groups[i]
items.push({
key: `${chunkPhase}-${chunkStart}-${group.id}-${i}`,
kind: "turn",
group: {
...group,
resources: group.userResources ?? [],
images: group.userImages ?? [],
},
phase: chunkPhase,
})
}
chunkStart = chunkEnd
}
const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null
if (
lastPhase === "optimistic" &&
(connStatus === "prompting" || sessionSyncState === "awaiting_persist")
) {
items.push({ key: "pending-typing", kind: "typing" })
}
return { threadItems: items, nonStreamingAdapted: nonStreaming }
}, [adapterText, connStatus, sessionSyncState, timelineTurns])
const historicalPlanEntries = useMemo(
() => extractLatestPlanEntriesFromMessages(nonStreamingAdapted),
[nonStreamingAdapted]
)
const historicalPlanKey = useMemo(
() => buildPlanKey(historicalPlanEntries),
[historicalPlanEntries]
)
const renderThreadItem = useCallback((item: ThreadRenderItem) => {
switch (item.kind) {
case "turn":
return (
)
case "typing":
return
default:
return null
}
}, [])
const emptyState = useMemo(
() =>
hideEmptyState ? null : (
),
[hideEmptyState, t]
)
const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}`
const hasRenderableContent = threadItems.length > 0 || Boolean(liveMessage)
if (detailLoading && !hasRenderableContent) {
return (
)
}
if (detailError && !hasRenderableContent) {
return (
{t("error", { message: detailError })}
)
}
return (
item.key}
renderItem={renderThreadItem}
emptyState={emptyState}
estimateSize={180}
overscan={10}
/>
{liveMessage && connStatus === "prompting" && (
)}
)
}