Files
codeg/src/components/message/message-list-view.tsx

360 lines
11 KiB
TypeScript

"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<string>()
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 (
<div
style={{
contentVisibility: "auto",
containIntrinsicSize: "auto 120px",
}}
>
<Message from={group.role}>
<MessageContent>
<ContentPartsRenderer parts={group.parts} role={group.role} />
</MessageContent>
{group.role === "user" && group.images.length > 0 ? (
<UserImageAttachments images={group.images} className="self-end" />
) : null}
{group.role === "user" && group.resources.length > 0 ? (
<UserResourceLinks resources={group.resources} className="self-end" />
) : null}
</Message>
{group.role === "assistant" && (
<TurnStats
usage={group.usage}
duration_ms={group.duration_ms}
model={group.model}
models={group.models}
/>
)}
</div>
)
})
const PendingMessageGroup = memo(function PendingMessageGroup({
group,
}: {
group: ResolvedMessageGroup
}) {
return (
<div className="opacity-70">
<Message from={group.role}>
<MessageContent>
<ContentPartsRenderer parts={group.parts} role={group.role} />
</MessageContent>
{group.role === "user" && group.images.length > 0 ? (
<UserImageAttachments images={group.images} className="self-end" />
) : null}
{group.role === "user" && group.resources.length > 0 ? (
<UserResourceLinks resources={group.resources} className="self-end" />
) : null}
</Message>
</div>
)
})
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 (
<div className="flex h-full items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t("loading")}</span>
</div>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="text-center py-12">
<p className="text-destructive text-sm">
{t("error", { message: error })}
</p>
</div>
</div>
)
}
if (!detail) return null
return (
<div className="relative flex h-full min-h-0 flex-col">
{/* Messages */}
<MessageThread
className="flex-1 min-h-0"
resize={shouldUseSmoothResize ? "smooth" : undefined}
>
<MessageThreadContent className="p-4 max-w-3xl mx-auto">
{resolvedGroups.length === 0 &&
resolvedPendingGroups.length === 0 &&
!showLiveMessage ? (
<div className="text-center py-12">
<p className="text-muted-foreground text-sm">
{t("emptyConversation")}
</p>
</div>
) : (
<>
{resolvedGroups.map((group) => (
<HistoricalMessageGroup key={group.id} group={group} />
))}
{resolvedPendingGroups.map((group) => (
<PendingMessageGroup key={group.id} group={group} />
))}
{resolvedPendingGroups.length > 0 && !showLiveMessage && (
<Message from="assistant">
<MessageContent>
<div className="flex items-center gap-1.5 py-1">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_infinite]" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_0.2s_infinite]" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_0.4s_infinite]" />
</div>
</MessageContent>
</Message>
)}
{showLiveMessage && liveMessage && (
<LiveMessageBlock message={liveMessage} />
)}
</>
)}
</MessageThreadContent>
</MessageThread>
{showLiveMessage && liveMessage && (
<LiveTurnStats message={liveMessage} />
)}
<AgentPlanOverlay
key={agentPlanOverlayKey}
message={liveMessage ?? null}
entries={historicalPlanEntries}
planKey={historicalPlanKey}
defaultExpanded={showLiveMessage}
/>
</div>
)
}