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