360 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|