From 53186c4ab59b7768c547454ffdaf602005ea0c51 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 8 Mar 2026 23:18:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E9=95=BF=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=97=B6=E8=99=9A=E6=8B=9F=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/welcome-input-panel.tsx | 131 +++++++++----- src/components/message/message-list-view.tsx | 168 ++++++++++++------ .../message/virtualized-message-thread.tsx | 99 +++++++++++ 3 files changed, 294 insertions(+), 104 deletions(-) create mode 100644 src/components/message/virtualized-message-thread.tsx diff --git a/src/components/chat/welcome-input-panel.tsx b/src/components/chat/welcome-input-panel.tsx index bde7f60..bf3428a 100644 --- a/src/components/chat/welcome-input-panel.tsx +++ b/src/components/chat/welcome-input-panel.tsx @@ -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 ( +
+ + {message.role === "user" && message.userImages?.length ? ( + + ) : null} + + + + {message.role === "user" && message.userResources?.length ? ( + + ) : null} + + {message.role === "assistant" && ( + + )} +
+ ) +}) + 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(() => { + 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 + } + return + }, []) + // ── 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 (
- - {history.map((msg) => ( -
- - {msg.role === "user" && msg.userImages?.length ? ( - - ) : null} - - - - {msg.role === "user" && msg.userResources?.length ? ( - - ) : null} - - {msg.role === "assistant" && ( - - )} -
- ))} - {showLive && } -
+ item.key} + renderItem={renderThreadItem} + estimateSize={180} + overscan={10} + />
{showLive && } +
{group.role === "user" && group.images.length > 0 ? ( @@ -176,6 +189,20 @@ const PendingMessageGroup = memo(function PendingMessageGroup({ ) }) +const PendingTypingIndicator = memo(function PendingTypingIndicator() { + return ( + + +
+ + + +
+
+
+ ) +}) + 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(() => { + 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 + case "pending": + return + case "typing": + return + case "live": + return + default: + return null + } + }, []) + + const emptyState = useMemo( + () => ( +
+

+ {t("emptyConversation")} +

+
+ ), + [t] + ) + const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}` if (loading && !detail) { @@ -304,45 +381,18 @@ export function MessageListView({ return (
- {/* Messages */} - - {resolvedGroups.length === 0 && - resolvedPendingGroups.length === 0 && - !showLiveMessage ? ( -
-

- {t("emptyConversation")} -

-
- ) : ( - <> - {resolvedGroups.map((group) => ( - - ))} - {resolvedPendingGroups.map((group) => ( - - ))} - {resolvedPendingGroups.length > 0 && !showLiveMessage && ( - - -
- - - -
-
-
- )} - {showLiveMessage && liveMessage && ( - - )} - - )} -
+ item.key} + renderItem={renderThreadItem} + emptyState={emptyState} + estimateSize={180} + overscan={10} + />
{showLiveMessage && liveMessage && ( diff --git a/src/components/message/virtualized-message-thread.tsx b/src/components/message/virtualized-message-thread.tsx new file mode 100644 index 0000000..5bc0125 --- /dev/null +++ b/src/components/message/virtualized-message-thread.tsx @@ -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 { + 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 +} + +export function VirtualizedMessageThread({ + items, + getItemKey, + renderItem, + emptyState, + estimateSize = 160, + overscan = 8, + className, + contentClassName, + contentProps, +}: VirtualizedMessageThreadProps) { + 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[number]) => { + const item = items[virtualItem.index] + if (!item) return null + + return ( +
+
+ {renderItem(item, virtualItem.index)} +
+
+ ) + }, + [className, items, renderItem, virtualizer] + ) + + return ( + + {items.length === 0 ? ( + (emptyState ?? null) + ) : ( +
+ {virtualizer.getVirtualItems().map(renderVirtualRow)} +
+ )} +
+ ) +}