会话长消息时虚拟渲染

This commit is contained in:
xintaofei
2026-03-08 23:18:41 +08:00
parent 5b416bd3bf
commit 53186c4ab5
3 changed files with 294 additions and 104 deletions

View File

@@ -1,13 +1,16 @@
"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 { MessageInput } from "@/components/chat/message-input"
import type { AgentType, PromptDraft, SessionStats } from "@/lib/types"
import { useFolderContext } from "@/contexts/folder-context"
import { useTabContext } from "@/contexts/tab-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 type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
import {
@@ -45,12 +48,10 @@ import { TurnStats } from "@/components/message/turn-stats"
import { UserResourceLinks } from "@/components/message/user-resource-links"
import { UserImageAttachments } from "@/components/message/user-image-attachments"
import { ConversationShell } from "@/components/chat/conversation-shell"
import {
MessageThread,
MessageThreadContent,
} from "@/components/ai-elements/message-thread"
import { MessageThread } from "@/components/ai-elements/message-thread"
import { Message, MessageContent } from "@/components/ai-elements/message"
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"
@@ -90,6 +91,49 @@ function buildInlineAutoConnectErrorMessage(
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({
defaultAgentType,
workingDir,
@@ -750,6 +794,35 @@ export function WelcomeInputPanel({
}
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 ──
if (phase === "welcome") {
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 (
<ConversationShell
status={connStatus}
@@ -852,37 +917,13 @@ export function WelcomeInputPanel({
>
<div className="relative flex flex-col h-full">
<MessageThread className="flex-1 min-h-0">
<MessageThreadContent className="p-4 max-w-3xl mx-auto">
{history.map((msg) => (
<div key={msg.id}>
<Message from={msg.role === "tool" ? "assistant" : msg.role}>
{msg.role === "user" && msg.userImages?.length ? (
<UserImageAttachments
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>
<VirtualizedMessageThread
items={threadItems}
getItemKey={(item) => item.key}
renderItem={renderThreadItem}
estimateSize={180}
overscan={10}
/>
</MessageThread>
{showLive && <LiveTurnStats message={conn.liveMessage!} />}
<AgentPlanOverlay

View File

@@ -1,6 +1,6 @@
"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 { ContentPartsRenderer } from "./content-parts-renderer"
import {
@@ -21,10 +21,7 @@ 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 { 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"
@@ -32,8 +29,8 @@ import {
buildPlanKey,
extractLatestPlanEntriesFromMessages,
} from "@/lib/agent-plan"
import type { ConnectionStatus } from "@/lib/types"
import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"
interface MessageListViewProps {
conversationId: number
@@ -50,6 +47,27 @@ interface ResolvedMessageGroup extends MessageGroup {
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(
group: MessageGroup,
attachedResourcesText: string
@@ -125,12 +143,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
group: ResolvedMessageGroup
}) {
return (
<div
style={{
contentVisibility: "auto",
containIntrinsicSize: "auto 120px",
}}
>
<div>
<Message from={group.role}>
{group.role === "user" && group.images.length > 0 ? (
<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({
conversationId,
connStatus,
@@ -189,7 +216,6 @@ export function MessageListView({
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
@@ -199,12 +225,10 @@ export function MessageListView({
}
}, [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
@@ -224,9 +248,6 @@ export function MessageListView({
}
}, [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(
@@ -255,19 +276,19 @@ export function MessageListView({
pendingMessages?.length ? groupAdaptedMessages(pendingMessages) : [],
[pendingMessages]
)
const attachedResourcesText = sharedT("attachedResources")
const resolvedGroups = useMemo(
() =>
groups.map((group) =>
resolveMessageGroup(group, sharedT("attachedResources"))
),
[groups, sharedT]
groups.map((group) => resolveMessageGroup(group, attachedResourcesText)),
[groups, attachedResourcesText]
)
const resolvedPendingGroups = useMemo(
() =>
pendingGroups.map((group) =>
resolveMessageGroup(group, sharedT("attachedResources"))
resolveMessageGroup(group, attachedResourcesText)
),
[pendingGroups, sharedT]
[pendingGroups, attachedResourcesText]
)
const showLiveMessage = Boolean(
@@ -275,6 +296,62 @@ export function MessageListView({
(connStatus === "prompting" ||
(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}`
if (loading && !detail) {
@@ -304,45 +381,18 @@ export function MessageListView({
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>
<VirtualizedMessageThread
items={threadItems}
getItemKey={(item) => item.key}
renderItem={renderThreadItem}
emptyState={emptyState}
estimateSize={180}
overscan={10}
/>
</MessageThread>
{showLiveMessage && liveMessage && (
<LiveTurnStats message={liveMessage} />

View 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>
)
}