From 11a5484b79780101790f6ee704f31299d759edf0 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 10 Mar 2026 20:02:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAgent=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=9C=A8=E5=93=8D=E5=BA=94=E7=BB=93=E6=9D=9F=E5=90=8E=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=B8=A4=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../conversation-detail-panel.tsx | 3 ++ src/components/message/message-list-view.tsx | 33 ++++++++++++++++++- src/contexts/conversation-runtime-context.tsx | 15 ++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index cee99e7..ea0cf1f 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -145,6 +145,7 @@ const ConversationTabView = memo(function ConversationTabView({ const [draftAgentType, setDraftAgentType] = useState(agentType) const selectedAgent = conversationId != null ? agentType : draftAgentType const [modeId, setModeId] = useState(null) + const [sendSignal, setSendSignal] = useState(0) const [agentsLoaded, setAgentsLoaded] = useState(false) const [usableAgentCount, setUsableAgentCount] = useState(0) const [agentConnectError, setAgentConnectError] = useState( @@ -433,6 +434,7 @@ const ConversationTabView = memo(function ConversationTabView({ optimisticTurn, optimisticTurn.id ) + setSendSignal((prev) => prev + 1) setSyncState(effectiveConversationId, "awaiting_persist") if (connStatus === "connected") { @@ -577,6 +579,7 @@ const ConversationTabView = memo(function ConversationTabView({ conversationId={effectiveConversationId} connStatus={connStatus} isActive={isActive} + sendSignal={sendSignal} /> ) diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 99b37a8..1a660be 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -1,6 +1,6 @@ "use client" -import { memo, useCallback, useEffect, useMemo } from "react" +import { memo, useCallback, useEffect, useMemo, useRef } from "react" import { useDbMessageDetail } from "@/hooks/use-db-message-detail" import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { ContentPartsRenderer } from "./content-parts-renderer" @@ -29,11 +29,13 @@ import { } from "@/lib/agent-plan" import type { ConnectionStatus } from "@/lib/types" import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread" +import { useStickToBottomContext } from "use-stick-to-bottom" interface MessageListViewProps { conversationId: number connStatus?: ConnectionStatus | null isActive?: boolean + sendSignal?: number } interface ResolvedMessageGroup extends MessageGroup { @@ -169,10 +171,38 @@ const PendingTypingIndicator = memo(function PendingTypingIndicator() { ) }) +const AutoScrollOnSend = memo(function AutoScrollOnSend({ + signal, + enabled, +}: { + signal: number + enabled: boolean +}) { + const { scrollToBottom } = useStickToBottomContext() + const lastSignalRef = useRef(signal) + + useEffect(() => { + if (!enabled) return + if (signal === lastSignalRef.current) return + lastSignalRef.current = signal + + scrollToBottom() + const rafId = requestAnimationFrame(() => { + scrollToBottom() + }) + return () => { + cancelAnimationFrame(rafId) + } + }, [enabled, scrollToBottom, signal]) + + return null +}) + export function MessageListView({ conversationId, connStatus, isActive = true, + sendSignal = 0, }: MessageListViewProps) { const t = useTranslations("Folder.chat.messageList") const sharedT = useTranslations("Folder.chat.shared") @@ -339,6 +369,7 @@ export function MessageListView({ className="flex-1 min-h-0" resize={shouldUseSmoothResize ? "smooth" : undefined} > + item.key} diff --git a/src/contexts/conversation-runtime-context.tsx b/src/contexts/conversation-runtime-context.tsx index 77abc03..30210b0 100644 --- a/src/contexts/conversation-runtime-context.tsx +++ b/src/contexts/conversation-runtime-context.tsx @@ -221,6 +221,9 @@ function reduceHydrateDetail( const current = state.byConversationId.get(conversationId) const nextExternalId = detail.summary.external_id ?? null const acceptSnapshot = shouldAcceptPersistedSnapshot(current, detail) + const prevPersistedTurnCount = current?.persistedTurns.length ?? 0 + const prevPersistedMessageCount = current?.persistedMessageCount ?? 0 + const prevPersistedUpdatedAt = current?.persistedUpdatedAt ?? null const optimisticTurns = current?.optimisticTurns ?? [] const persistedTurns = acceptSnapshot ? detail.turns @@ -234,11 +237,20 @@ function reduceHydrateDetail( const shouldDropOptimistic = optimisticTurns.length > 0 && persistedTurns.length >= (current?.persistedTurns.length ?? 0) + 1 + const nextUpdatedAt = detail.summary.updated_at ?? null + const hasPersistedAdvance = + acceptSnapshot && + (detail.turns.length > prevPersistedTurnCount || + detail.summary.message_count > prevPersistedMessageCount || + (nextUpdatedAt !== null && + (prevPersistedUpdatedAt === null || + nextUpdatedAt > prevPersistedUpdatedAt))) const nextSession: ConversationRuntimeSession = { ...(current ?? createEmptySession(conversationId)), externalId: nextExternalId, persistedTurns, + liveMessage: hasPersistedAdvance ? null : (current?.liveMessage ?? null), optimisticTurns: shouldDropOptimistic ? [] : optimisticTurns, syncState: shouldDropOptimistic ? "idle" : (current?.syncState ?? "idle"), activeTurnToken: shouldDropOptimistic @@ -370,6 +382,7 @@ function reducer( const preferFromSnapshot = from.persistedTurns.length >= to.persistedTurns.length + const mergedLiveMessage = to.liveMessage ?? from.liveMessage const merged: ConversationRuntimeSession = { ...to, @@ -379,7 +392,7 @@ function reducer( ? from.persistedTurns : to.persistedTurns, optimisticTurns: [...from.optimisticTurns, ...to.optimisticTurns], - liveMessage: to.liveMessage ?? from.liveMessage, + liveMessage: mergedLiveMessage, syncState: to.syncState !== "idle" ? to.syncState : from.syncState, activeTurnToken: to.activeTurnToken ?? from.activeTurnToken, lastHydratedAt: