会话长消息时虚拟渲染
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { MessageInput } from "@/components/chat/message-input"
|
import { MessageInput } from "@/components/chat/message-input"
|
||||||
import type { AgentType, PromptDraft, SessionStats } from "@/lib/types"
|
import type { AgentType, PromptDraft, SessionStats } from "@/lib/types"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useTabContext } from "@/contexts/tab-context"
|
import { useTabContext } from "@/contexts/tab-context"
|
||||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||||
import { useAcpActions } from "@/contexts/acp-connections-context"
|
import {
|
||||||
|
useAcpActions,
|
||||||
|
type LiveMessage,
|
||||||
|
} from "@/contexts/acp-connections-context"
|
||||||
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
||||||
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
||||||
import {
|
import {
|
||||||
@@ -45,12 +48,10 @@ import { TurnStats } from "@/components/message/turn-stats"
|
|||||||
import { UserResourceLinks } from "@/components/message/user-resource-links"
|
import { UserResourceLinks } from "@/components/message/user-resource-links"
|
||||||
import { UserImageAttachments } from "@/components/message/user-image-attachments"
|
import { UserImageAttachments } from "@/components/message/user-image-attachments"
|
||||||
import { ConversationShell } from "@/components/chat/conversation-shell"
|
import { ConversationShell } from "@/components/chat/conversation-shell"
|
||||||
import {
|
import { MessageThread } from "@/components/ai-elements/message-thread"
|
||||||
MessageThread,
|
|
||||||
MessageThreadContent,
|
|
||||||
} from "@/components/ai-elements/message-thread"
|
|
||||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
import { Message, MessageContent } from "@/components/ai-elements/message"
|
||||||
import { ContentPartsRenderer } from "@/components/message/content-parts-renderer"
|
import { ContentPartsRenderer } from "@/components/message/content-parts-renderer"
|
||||||
|
import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"
|
||||||
|
|
||||||
const ACP_AGENTS_UPDATED_EVENT = "app://acp-agents-updated"
|
const ACP_AGENTS_UPDATED_EVENT = "app://acp-agents-updated"
|
||||||
|
|
||||||
@@ -90,6 +91,49 @@ function buildInlineAutoConnectErrorMessage(
|
|||||||
return options.append(normalized)
|
return options.append(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WelcomeThreadItem =
|
||||||
|
| { key: string; kind: "history"; message: AdaptedMessage }
|
||||||
|
| {
|
||||||
|
key: string
|
||||||
|
kind: "live"
|
||||||
|
message: LiveMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
const WelcomeHistoryMessage = memo(function WelcomeHistoryMessage({
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
message: AdaptedMessage
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Message from={message.role === "tool" ? "assistant" : message.role}>
|
||||||
|
{message.role === "user" && message.userImages?.length ? (
|
||||||
|
<UserImageAttachments
|
||||||
|
images={message.userImages}
|
||||||
|
className="self-end"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<MessageContent>
|
||||||
|
<ContentPartsRenderer parts={message.content} role={message.role} />
|
||||||
|
</MessageContent>
|
||||||
|
{message.role === "user" && message.userResources?.length ? (
|
||||||
|
<UserResourceLinks
|
||||||
|
resources={message.userResources}
|
||||||
|
className="self-end"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Message>
|
||||||
|
{message.role === "assistant" && (
|
||||||
|
<TurnStats
|
||||||
|
usage={message.usage}
|
||||||
|
duration_ms={message.duration_ms}
|
||||||
|
model={message.model}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
export function WelcomeInputPanel({
|
export function WelcomeInputPanel({
|
||||||
defaultAgentType,
|
defaultAgentType,
|
||||||
workingDir,
|
workingDir,
|
||||||
@@ -750,6 +794,35 @@ export function WelcomeInputPanel({
|
|||||||
}
|
}
|
||||||
prevHistoryLenRef.current = history.length
|
prevHistoryLenRef.current = history.length
|
||||||
|
|
||||||
|
const showLive = Boolean(
|
||||||
|
conn.liveMessage &&
|
||||||
|
(connStatus === "prompting" ||
|
||||||
|
(conn.liveMessage.content.length > 0 && showLiveTransitionRef.current))
|
||||||
|
)
|
||||||
|
|
||||||
|
const threadItems = useMemo<WelcomeThreadItem[]>(() => {
|
||||||
|
const items: WelcomeThreadItem[] = history.map((message) => ({
|
||||||
|
key: `history-${message.id}`,
|
||||||
|
kind: "history",
|
||||||
|
message,
|
||||||
|
}))
|
||||||
|
if (showLive && conn.liveMessage) {
|
||||||
|
items.push({
|
||||||
|
key: `live-${conn.liveMessage.id}`,
|
||||||
|
kind: "live",
|
||||||
|
message: conn.liveMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [history, showLive, conn.liveMessage])
|
||||||
|
|
||||||
|
const renderThreadItem = useCallback((item: WelcomeThreadItem) => {
|
||||||
|
if (item.kind === "live") {
|
||||||
|
return <LiveMessageBlock message={item.message} />
|
||||||
|
}
|
||||||
|
return <WelcomeHistoryMessage message={item.message} />
|
||||||
|
}, [])
|
||||||
|
|
||||||
// ── Welcome phase ──
|
// ── Welcome phase ──
|
||||||
if (phase === "welcome") {
|
if (phase === "welcome") {
|
||||||
return (
|
return (
|
||||||
@@ -820,14 +893,6 @@ export function WelcomeInputPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Conversation phase ──
|
|
||||||
|
|
||||||
const showLive = Boolean(
|
|
||||||
conn.liveMessage &&
|
|
||||||
(connStatus === "prompting" ||
|
|
||||||
(conn.liveMessage.content.length > 0 && showLiveTransitionRef.current))
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConversationShell
|
<ConversationShell
|
||||||
status={connStatus}
|
status={connStatus}
|
||||||
@@ -852,37 +917,13 @@ export function WelcomeInputPanel({
|
|||||||
>
|
>
|
||||||
<div className="relative flex flex-col h-full">
|
<div className="relative flex flex-col h-full">
|
||||||
<MessageThread className="flex-1 min-h-0">
|
<MessageThread className="flex-1 min-h-0">
|
||||||
<MessageThreadContent className="p-4 max-w-3xl mx-auto">
|
<VirtualizedMessageThread
|
||||||
{history.map((msg) => (
|
items={threadItems}
|
||||||
<div key={msg.id}>
|
getItemKey={(item) => item.key}
|
||||||
<Message from={msg.role === "tool" ? "assistant" : msg.role}>
|
renderItem={renderThreadItem}
|
||||||
{msg.role === "user" && msg.userImages?.length ? (
|
estimateSize={180}
|
||||||
<UserImageAttachments
|
overscan={10}
|
||||||
images={msg.userImages}
|
/>
|
||||||
className="self-end"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<MessageContent>
|
|
||||||
<ContentPartsRenderer parts={msg.content} role={msg.role} />
|
|
||||||
</MessageContent>
|
|
||||||
{msg.role === "user" && msg.userResources?.length ? (
|
|
||||||
<UserResourceLinks
|
|
||||||
resources={msg.userResources}
|
|
||||||
className="self-end"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Message>
|
|
||||||
{msg.role === "assistant" && (
|
|
||||||
<TurnStats
|
|
||||||
usage={msg.usage}
|
|
||||||
duration_ms={msg.duration_ms}
|
|
||||||
model={msg.model}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{showLive && <LiveMessageBlock message={conn.liveMessage!} />}
|
|
||||||
</MessageThreadContent>
|
|
||||||
</MessageThread>
|
</MessageThread>
|
||||||
{showLive && <LiveTurnStats message={conn.liveMessage!} />}
|
{showLive && <LiveTurnStats message={conn.liveMessage!} />}
|
||||||
<AgentPlanOverlay
|
<AgentPlanOverlay
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { memo, useEffect, useMemo, useRef } from "react"
|
import { memo, useCallback, useEffect, useMemo, useRef } from "react"
|
||||||
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
|
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
|
||||||
import { ContentPartsRenderer } from "./content-parts-renderer"
|
import { ContentPartsRenderer } from "./content-parts-renderer"
|
||||||
import {
|
import {
|
||||||
@@ -21,10 +21,7 @@ import { useSessionStats } from "@/contexts/session-stats-context"
|
|||||||
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
||||||
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
||||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||||
import {
|
import { MessageThread } from "@/components/ai-elements/message-thread"
|
||||||
MessageThread,
|
|
||||||
MessageThreadContent,
|
|
||||||
} from "@/components/ai-elements/message-thread"
|
|
||||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
import { Message, MessageContent } from "@/components/ai-elements/message"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
@@ -32,8 +29,8 @@ import {
|
|||||||
buildPlanKey,
|
buildPlanKey,
|
||||||
extractLatestPlanEntriesFromMessages,
|
extractLatestPlanEntriesFromMessages,
|
||||||
} from "@/lib/agent-plan"
|
} from "@/lib/agent-plan"
|
||||||
|
|
||||||
import type { ConnectionStatus } from "@/lib/types"
|
import type { ConnectionStatus } from "@/lib/types"
|
||||||
|
import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"
|
||||||
|
|
||||||
interface MessageListViewProps {
|
interface MessageListViewProps {
|
||||||
conversationId: number
|
conversationId: number
|
||||||
@@ -50,6 +47,27 @@ interface ResolvedMessageGroup extends MessageGroup {
|
|||||||
images: UserImageDisplay[]
|
images: UserImageDisplay[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ThreadRenderItem =
|
||||||
|
| {
|
||||||
|
key: string
|
||||||
|
kind: "historical"
|
||||||
|
group: ResolvedMessageGroup
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: string
|
||||||
|
kind: "pending"
|
||||||
|
group: ResolvedMessageGroup
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: string
|
||||||
|
kind: "typing"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: string
|
||||||
|
kind: "live"
|
||||||
|
message: LiveMessage
|
||||||
|
}
|
||||||
|
|
||||||
function fallbackExtractUserResources(
|
function fallbackExtractUserResources(
|
||||||
group: MessageGroup,
|
group: MessageGroup,
|
||||||
attachedResourcesText: string
|
attachedResourcesText: string
|
||||||
@@ -125,12 +143,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
|
|||||||
group: ResolvedMessageGroup
|
group: ResolvedMessageGroup
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
style={{
|
|
||||||
contentVisibility: "auto",
|
|
||||||
containIntrinsicSize: "auto 120px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Message from={group.role}>
|
<Message from={group.role}>
|
||||||
{group.role === "user" && group.images.length > 0 ? (
|
{group.role === "user" && group.images.length > 0 ? (
|
||||||
<UserImageAttachments images={group.images} className="self-end" />
|
<UserImageAttachments images={group.images} className="self-end" />
|
||||||
@@ -176,6 +189,20 @@ const PendingMessageGroup = memo(function PendingMessageGroup({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const PendingTypingIndicator = memo(function PendingTypingIndicator() {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
export function MessageListView({
|
export function MessageListView({
|
||||||
conversationId,
|
conversationId,
|
||||||
connStatus,
|
connStatus,
|
||||||
@@ -189,7 +216,6 @@ export function MessageListView({
|
|||||||
const { detail, loading, error, refetch } = useDbMessageDetail(conversationId)
|
const { detail, loading, error, refetch } = useDbMessageDetail(conversationId)
|
||||||
const turnCount = detail?.turns.length ?? 0
|
const turnCount = detail?.turns.length ?? 0
|
||||||
|
|
||||||
// Refetch when agent turn completes (prompting → other status)
|
|
||||||
const prevStatusRef = useRef(connStatus)
|
const prevStatusRef = useRef(connStatus)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = prevStatusRef.current
|
const prev = prevStatusRef.current
|
||||||
@@ -199,12 +225,10 @@ export function MessageListView({
|
|||||||
}
|
}
|
||||||
}, [connStatus, refetch])
|
}, [connStatus, refetch])
|
||||||
|
|
||||||
// Clear pending when detail gains new turns (new data fetched successfully)
|
|
||||||
const prevTurnCountRef = useRef(turnCount)
|
const prevTurnCountRef = useRef(turnCount)
|
||||||
const prevConvIdRef = useRef(conversationId)
|
const prevConvIdRef = useRef(conversationId)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevConvIdRef.current !== conversationId) {
|
if (prevConvIdRef.current !== conversationId) {
|
||||||
// Conversation switched — reset baseline, don't clear
|
|
||||||
prevConvIdRef.current = conversationId
|
prevConvIdRef.current = conversationId
|
||||||
prevTurnCountRef.current = turnCount
|
prevTurnCountRef.current = turnCount
|
||||||
return
|
return
|
||||||
@@ -224,9 +248,6 @@ export function MessageListView({
|
|||||||
}
|
}
|
||||||
}, [isActive, sessionStats, setSessionStats])
|
}, [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 shouldUseSmoothResize = !(isActive && !loading && detail)
|
||||||
|
|
||||||
const messages = useMemo(
|
const messages = useMemo(
|
||||||
@@ -255,19 +276,19 @@ export function MessageListView({
|
|||||||
pendingMessages?.length ? groupAdaptedMessages(pendingMessages) : [],
|
pendingMessages?.length ? groupAdaptedMessages(pendingMessages) : [],
|
||||||
[pendingMessages]
|
[pendingMessages]
|
||||||
)
|
)
|
||||||
|
const attachedResourcesText = sharedT("attachedResources")
|
||||||
|
|
||||||
const resolvedGroups = useMemo(
|
const resolvedGroups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
groups.map((group) =>
|
groups.map((group) => resolveMessageGroup(group, attachedResourcesText)),
|
||||||
resolveMessageGroup(group, sharedT("attachedResources"))
|
[groups, attachedResourcesText]
|
||||||
),
|
|
||||||
[groups, sharedT]
|
|
||||||
)
|
)
|
||||||
const resolvedPendingGroups = useMemo(
|
const resolvedPendingGroups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pendingGroups.map((group) =>
|
pendingGroups.map((group) =>
|
||||||
resolveMessageGroup(group, sharedT("attachedResources"))
|
resolveMessageGroup(group, attachedResourcesText)
|
||||||
),
|
),
|
||||||
[pendingGroups, sharedT]
|
[pendingGroups, attachedResourcesText]
|
||||||
)
|
)
|
||||||
|
|
||||||
const showLiveMessage = Boolean(
|
const showLiveMessage = Boolean(
|
||||||
@@ -275,6 +296,62 @@ export function MessageListView({
|
|||||||
(connStatus === "prompting" ||
|
(connStatus === "prompting" ||
|
||||||
(liveMessage.content.length > 0 && resolvedPendingGroups.length > 0))
|
(liveMessage.content.length > 0 && resolvedPendingGroups.length > 0))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const threadItems = useMemo<ThreadRenderItem[]>(() => {
|
||||||
|
const items: ThreadRenderItem[] = [
|
||||||
|
...resolvedGroups.map((group) => ({
|
||||||
|
key: `history-${group.id}`,
|
||||||
|
kind: "historical" as const,
|
||||||
|
group,
|
||||||
|
})),
|
||||||
|
...resolvedPendingGroups.map((group) => ({
|
||||||
|
key: `pending-${group.id}`,
|
||||||
|
kind: "pending" as const,
|
||||||
|
group,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (resolvedPendingGroups.length > 0 && !showLiveMessage) {
|
||||||
|
items.push({ key: "pending-typing", kind: "typing" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLiveMessage && liveMessage) {
|
||||||
|
items.push({
|
||||||
|
key: `live-${liveMessage.id}`,
|
||||||
|
kind: "live",
|
||||||
|
message: liveMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}, [resolvedGroups, resolvedPendingGroups, showLiveMessage, liveMessage])
|
||||||
|
|
||||||
|
const renderThreadItem = useCallback((item: ThreadRenderItem) => {
|
||||||
|
switch (item.kind) {
|
||||||
|
case "historical":
|
||||||
|
return <HistoricalMessageGroup group={item.group} />
|
||||||
|
case "pending":
|
||||||
|
return <PendingMessageGroup group={item.group} />
|
||||||
|
case "typing":
|
||||||
|
return <PendingTypingIndicator />
|
||||||
|
case "live":
|
||||||
|
return <LiveMessageBlock message={item.message} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const emptyState = useMemo(
|
||||||
|
() => (
|
||||||
|
<div className="px-4 py-12 text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("emptyConversation")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}`
|
const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}`
|
||||||
|
|
||||||
if (loading && !detail) {
|
if (loading && !detail) {
|
||||||
@@ -304,45 +381,18 @@ export function MessageListView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full min-h-0 flex-col">
|
<div className="relative flex h-full min-h-0 flex-col">
|
||||||
{/* Messages */}
|
|
||||||
<MessageThread
|
<MessageThread
|
||||||
className="flex-1 min-h-0"
|
className="flex-1 min-h-0"
|
||||||
resize={shouldUseSmoothResize ? "smooth" : undefined}
|
resize={shouldUseSmoothResize ? "smooth" : undefined}
|
||||||
>
|
>
|
||||||
<MessageThreadContent className="p-4 max-w-3xl mx-auto">
|
<VirtualizedMessageThread
|
||||||
{resolvedGroups.length === 0 &&
|
items={threadItems}
|
||||||
resolvedPendingGroups.length === 0 &&
|
getItemKey={(item) => item.key}
|
||||||
!showLiveMessage ? (
|
renderItem={renderThreadItem}
|
||||||
<div className="text-center py-12">
|
emptyState={emptyState}
|
||||||
<p className="text-muted-foreground text-sm">
|
estimateSize={180}
|
||||||
{t("emptyConversation")}
|
overscan={10}
|
||||||
</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>
|
</MessageThread>
|
||||||
{showLiveMessage && liveMessage && (
|
{showLiveMessage && liveMessage && (
|
||||||
<LiveTurnStats message={liveMessage} />
|
<LiveTurnStats message={liveMessage} />
|
||||||
|
|||||||
99
src/components/message/virtualized-message-thread.tsx
Normal file
99
src/components/message/virtualized-message-thread.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback } from "react"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||||
|
import { useStickToBottomContext } from "use-stick-to-bottom"
|
||||||
|
import {
|
||||||
|
MessageThreadContent,
|
||||||
|
type MessageThreadContentProps,
|
||||||
|
} from "@/components/ai-elements/message-thread"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface VirtualizedMessageThreadProps<T> {
|
||||||
|
items: T[]
|
||||||
|
getItemKey: (item: T, index: number) => string
|
||||||
|
renderItem: (item: T, index: number) => ReactNode
|
||||||
|
emptyState?: ReactNode
|
||||||
|
estimateSize?: number
|
||||||
|
overscan?: number
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
contentProps?: Omit<MessageThreadContentProps, "children" | "className">
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VirtualizedMessageThread<T>({
|
||||||
|
items,
|
||||||
|
getItemKey,
|
||||||
|
renderItem,
|
||||||
|
emptyState,
|
||||||
|
estimateSize = 160,
|
||||||
|
overscan = 8,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
contentProps,
|
||||||
|
}: VirtualizedMessageThreadProps<T>) {
|
||||||
|
const { scrollRef } = useStickToBottomContext()
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => estimateSize,
|
||||||
|
overscan,
|
||||||
|
useAnimationFrameWithResizeObserver: true,
|
||||||
|
isScrollingResetDelay: 100,
|
||||||
|
paddingStart: 16,
|
||||||
|
paddingEnd: 16,
|
||||||
|
gap: 32,
|
||||||
|
getItemKey: (index) => {
|
||||||
|
const item = items[index]
|
||||||
|
return item ? getItemKey(item, index) : index
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderVirtualRow = useCallback(
|
||||||
|
(virtualItem: ReturnType<typeof virtualizer.getVirtualItems>[number]) => {
|
||||||
|
const item = items[virtualItem.index]
|
||||||
|
if (!item) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
data-index={virtualItem.index}
|
||||||
|
className="absolute left-0 top-0 w-full"
|
||||||
|
style={{
|
||||||
|
transform: `translate3d(0, ${virtualItem.start}px, 0)`,
|
||||||
|
willChange: "transform",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={cn("mx-auto max-w-3xl px-4", className)}>
|
||||||
|
{renderItem(item, virtualItem.index)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[className, items, renderItem, virtualizer]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageThreadContent
|
||||||
|
className={cn("mx-0 max-w-none p-0", contentClassName)}
|
||||||
|
{...contentProps}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
(emptyState ?? null)
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map(renderVirtualRow)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MessageThreadContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user