From 34ae8b059e9eebfac8e977d587d500d8e3d5c5f0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 15:01:55 +0300 Subject: [PATCH] refactor(chat): drop the inert content-change and animation-handler contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old scroll engine needed message parts to report content growth (onContentChange) and per-message animation lifecycle callbacks (AnimationHandlers) so it could re-pin the viewport. The timeline list measures growth itself now, and the replacement hook had already stubbed the whole contract with no-ops kept only for source compatibility. Remove it end to end: the hook exports, the container and list threading, the ChatMessage/MessageBody signal-only effects, and every part-level prop and call site. Expand/collapse behavior and reveal animations are untouched — only the reporting channel goes. --- .../ui/src/components/chat/ChatContainer.tsx | 21 +-- .../ui/src/components/chat/ChatMessage.tsx | 141 ------------------ .../ui/src/components/chat/MessageList.tsx | 64 +------- .../ui/src/components/chat/btw/BtwPanel.tsx | 4 - .../chat/components/TurnActivity.tsx | 2 - .../components/chat/message/MessageBody.tsx | 56 ------- .../chat/message/parts/AssistantTextPart.tsx | 2 - .../chat/message/parts/JustificationBlock.tsx | 4 - .../chat/message/parts/ProgressiveGroup.tsx | 19 +-- .../chat/message/parts/ReasoningPart.tsx | 26 +--- .../chat/message/parts/ToolPart.tsx | 34 +---- .../ui/src/hooks/useChatTimelineScroll.ts | 34 ----- 12 files changed, 6 insertions(+), 401 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index c3db9e76..238a4db4 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -19,7 +19,7 @@ import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { PromptNavigatorRail } from './components/PromptNavigatorRail'; import { useScrollShadow } from '@/components/ui/useScrollShadow'; -import { useChatTimelineScroll, type AnimationHandlers, type ContentChangeReason, type TimelineListHandle } from '@/hooks/useChatTimelineScroll'; +import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { TimelineDialog } from './TimelineDialog'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; @@ -172,8 +172,6 @@ type ChatViewportProps = { confirmedAt?: number; fallbackTimestamp?: number; } | null; - handleMessageContentChange: (reason?: ContentChangeReason) => void; - getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom: () => void; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; @@ -210,8 +208,6 @@ const ChatViewport = React.memo(({ streamingMessageId, activeStreamingPhase, retryOverlay, - handleMessageContentChange, - getAnimationHandlers, scrollToBottom, sessionQuestions, sessionPermissions, @@ -394,8 +390,6 @@ const ChatViewport = React.memo(({ activeStreamingMessageId={streamingMessageId} activeStreamingPhase={activeStreamingPhase} retryOverlay={retryOverlay} - onMessageContentChange={handleMessageContentChange} - getAnimationHandlers={getAnimationHandlers} isLoadingOlder={isLoadingOlder} scrollToBottom={scrollToBottom} directory={directory} @@ -443,8 +437,6 @@ const ChatViewport = React.memo(({ && prev.streamingMessageId === next.streamingMessageId && prev.activeStreamingPhase === next.activeStreamingPhase && prev.retryOverlay === next.retryOverlay - && prev.handleMessageContentChange === next.handleMessageContentChange - && prev.getAnimationHandlers === next.getAnimationHandlers && prev.scrollToBottom === next.scrollToBottom && prev.sessionQuestions === next.sessionQuestions && prev.sessionPermissions === next.sessionPermissions @@ -959,8 +951,6 @@ export const ChatContainer: React.FC = ({ onIsAtEndChange, onManualNavigation, onTimelineDataChange, - notifyContentChange: handleMessageContentChange, - getAnimationHandlers, goToBottom, scrollToBottomOnSend, restoreSnapshot, @@ -1026,13 +1016,6 @@ export const ChatContainer: React.FC = ({ activeTurnChangeRef.current = timelineController.handleActiveTurnChange; }, [timelineController.handleActiveTurnChange]); - React.useEffect(() => { - if (sessionPermissions.length === 0 && sessionQuestions.length === 0) { - return; - } - handleMessageContentChange('permission'); - }, [handleMessageContentChange, sessionPermissions, sessionQuestions]); - const navigation = useChatTurnNavigation({ sessionId: currentSessionId, turnIds: timelineController.turnIds, @@ -1365,8 +1348,6 @@ export const ChatContainer: React.FC = ({ streamingMessageId={streamingMessageId} activeStreamingPhase={activeStreamingPhase} retryOverlay={retryOverlay} - handleMessageContentChange={handleMessageContentChange} - getAnimationHandlers={getAnimationHandlers} scrollToBottom={resumeToLatestInstant} sessionQuestions={sessionQuestions} sessionPermissions={sessionPermissions} diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 813b91ee..a4327167 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -14,7 +14,6 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { cn } from '@/lib/utils'; import { useChatSurfaceMode } from './useChatSurfaceMode'; -import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatTimelineScroll'; import MessageBody from './message/MessageBody'; import type { AgentMentionInfo } from './message/types'; import type { StreamPhase, ToolPopupContent } from './message/types'; @@ -132,8 +131,6 @@ interface ChatMessageProps { info: Message; parts: Part[]; }; - onContentChange?: (reason?: ContentChangeReason) => void; - animationHandlers?: AnimationHandlers; scrollToBottom?: () => void; turnGroupingContext?: TurnGroupingContext; assistantHeaderMessageId?: string; @@ -148,8 +145,6 @@ const ChatMessage: React.FC = ({ message, previousMessage, nextMessage, - onContentChange, - animationHandlers, turnGroupingContext, assistantHeaderMessageId, isInActiveTurn = false, @@ -850,35 +845,12 @@ const ChatMessage: React.FC = ({ }); }, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]); - const resolvedAnimationHandlers = animationHandlers ?? null; - const hasAnnouncedAuxiliaryScrollRef = React.useRef(false); - - const animationCompletedRef = React.useRef(false); - const hasRequestedReservationRef = React.useRef(false); - const animationStartNotifiedRef = React.useRef(false); - const hasTriggeredReservationOnceRef = React.useRef(false); const hasEverStreamedRef = React.useRef(false); React.useEffect(() => { - animationCompletedRef.current = false; - hasRequestedReservationRef.current = false; - animationStartNotifiedRef.current = false; - hasTriggeredReservationOnceRef.current = false; - hasAnnouncedAuxiliaryScrollRef.current = false; hasEverStreamedRef.current = false; }, [message.info.id]); - const handleAuxiliaryContentComplete = React.useCallback(() => { - if (isUser) { - return; - } - if (hasAnnouncedAuxiliaryScrollRef.current) { - return; - } - hasAnnouncedAuxiliaryScrollRef.current = true; - onContentChange?.('structural'); - }, [isUser, onContentChange]); - const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen); const handleShowPopup = React.useCallback((content: ToolPopupContent) => { @@ -901,114 +873,7 @@ const ChatMessage: React.FC = ({ hasEverStreamedRef.current = true; } - const hasReasoningParts = React.useMemo(() => { - if (isUser) { - return false; - } - return visibleParts.some((part) => part.type === 'reasoning'); - }, [isUser, visibleParts]); - const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current; - const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering; - - React.useEffect(() => { - if (!resolvedAnimationHandlers?.onStreamingCandidate) { - return; - } - - if (!shouldReserveAnimationSpace) { - if (hasRequestedReservationRef.current) { - if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) { - resolvedAnimationHandlers.onReasoningBlock(); - } else if (resolvedAnimationHandlers?.onReservationCancelled) { - resolvedAnimationHandlers.onReservationCancelled(); - } - hasRequestedReservationRef.current = false; - } - return; - } - - if (hasTriggeredReservationOnceRef.current) { - return; - } - - hasTriggeredReservationOnceRef.current = true; - resolvedAnimationHandlers.onStreamingCandidate(); - hasRequestedReservationRef.current = true; - }, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]); - - React.useEffect(() => { - if (!resolvedAnimationHandlers?.onAnimationStart) { - return; - } - if (!allowAnimation) { - return; - } - if (animationStartNotifiedRef.current) { - return; - } - resolvedAnimationHandlers.onAnimationStart(); - animationStartNotifiedRef.current = true; - }, [resolvedAnimationHandlers, allowAnimation]); - - React.useEffect(() => { - if (isUser) { - return; - } - - const handler = resolvedAnimationHandlers?.onAnimatedHeightChange; - if (!handler) { - return; - } - - const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace; - if (!shouldTrackHeight) { - return; - } - - const element = messageContainerRef.current; - if (!element) { - return; - } - - if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') { - handler(element.getBoundingClientRect().height); - return; - } - - let rafId: number | null = null; - const notifyHeight = (height: number) => { - if (typeof window === 'undefined') { - handler(height); - return; - } - if (rafId !== null) { - window.cancelAnimationFrame(rafId); - } - rafId = window.requestAnimationFrame(() => { - handler(height); - }); - }; - - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) { - return; - } - notifyHeight(entry.contentRect.height); - }); - - observer.observe(element); - notifyHeight(element.getBoundingClientRect().height); - - return () => { - if (rafId !== null) { - window.cancelAnimationFrame(rafId); - rafId = null; - } - observer.disconnect(); - }; - }, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]); if (shouldHideUserMessage) { return null; @@ -1070,13 +935,11 @@ const ChatMessage: React.FC = ({ onShowPopup={handleShowPopup} streamPhase={streamPhase} allowAnimation={allowAnimation} - onContentChange={onContentChange} shouldShowHeader={false} hasTextContent={hasTextContent} onCopyMessage={handleCopyMessage} copiedMessage={copiedMessage} showReasoningTraces={showReasoningTraces} - onAuxiliaryContentComplete={handleAuxiliaryContentComplete} agentMention={agentMention} onRevert={handleRevert} onFork={isUser ? handleFork : undefined} @@ -1106,13 +969,11 @@ const ChatMessage: React.FC = ({ onShowPopup={handleShowPopup} streamPhase={streamPhase} allowAnimation={allowAnimation} - onContentChange={onContentChange} shouldShowHeader={false} hasTextContent={hasTextContent} onCopyMessage={handleCopyMessage} copiedMessage={copiedMessage} showReasoningTraces={showReasoningTraces} - onAuxiliaryContentComplete={handleAuxiliaryContentComplete} agentMention={agentMention} onRevert={handleRevert} onFork={isUser ? handleFork : undefined} @@ -1152,12 +1013,10 @@ const ChatMessage: React.FC = ({ onShowPopup={handleShowPopup} streamPhase={streamPhase} allowAnimation={allowAnimation} - onContentChange={onContentChange} shouldShowHeader={shouldShowHeader} hasTextContent={hasTextContent} onCopyMessage={handleCopyMessage} copiedMessage={copiedMessage} - onAuxiliaryContentComplete={handleAuxiliaryContentComplete} showReasoningTraces={showReasoningTraces} agentMention={agentMention} turnGroupingContext={turnGroupingContext} diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 4a162760..1a8ea34e 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -5,7 +5,6 @@ import { LegendList, type LegendListRef } from '@legendapp/list/react'; import ChatMessage from './ChatMessage'; import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; import TurnItem from './components/TurnItem'; -import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatTimelineScroll'; import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types'; import { useTurnRecords } from './hooks/useTurnRecords'; import { applyRetryOverlay } from './lib/turns/applyRetryOverlay'; @@ -317,8 +316,6 @@ interface MessageListProps { confirmedAt?: number; fallbackTimestamp?: number; } | null; - onMessageContentChange: (reason?: ContentChangeReason) => void; - getAnimationHandlers: (messageId: string) => AnimationHandlers; isLoadingOlder: boolean; scrollToBottom?: () => void; directory?: string; @@ -375,8 +372,6 @@ interface MessageRowProps { activeStreamingPhase?: StreamPhase | null; animateUserOnMount?: boolean; onUserAnimationConsumed?: (messageId: string) => void; - onContentChange: (reason?: ContentChangeReason) => void; - animationHandlers: AnimationHandlers; scrollToBottom?: () => void; reviewTransferDirection?: ReviewTransferDirection | null; } @@ -391,8 +386,6 @@ const MessageRow = React.memo(({ activeStreamingPhase, animateUserOnMount, onUserAnimationConsumed, - onContentChange, - animationHandlers, scrollToBottom, reviewTransferDirection, }) => { @@ -403,8 +396,6 @@ const MessageRow = React.memo(({ nextMessage={nextMessage} animateUserOnMount={animateUserOnMount} onUserAnimationConsumed={onUserAnimationConsumed} - onContentChange={onContentChange} - animationHandlers={animationHandlers} scrollToBottom={scrollToBottom} turnGroupingContext={turnGroupingContext} assistantHeaderMessageId={assistantHeaderMessageId} @@ -422,20 +413,12 @@ const MessageRow = React.memo(({ && areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage) && prev.animateUserOnMount === next.animateUserOnMount && prev.onUserAnimationConsumed === next.onUserAnimationConsumed - && prev.onContentChange === next.onContentChange && prev.scrollToBottom === next.scrollToBottom && areRelevantTurnGroupingContextsEqual(prevTurn, nextTurn, prev.message.info.id, resolveMessageRole(prev.message) === 'user') && prev.assistantHeaderMessageId === next.assistantHeaderMessageId && prev.isInActiveTurn === next.isInActiveTurn && prev.activeStreamingPhase === next.activeStreamingPhase - && prev.reviewTransferDirection === next.reviewTransferDirection - && prev.animationHandlers?.onChunk === next.animationHandlers?.onChunk - && prev.animationHandlers?.onComplete === next.animationHandlers?.onComplete - && prev.animationHandlers?.onStreamingCandidate === next.animationHandlers?.onStreamingCandidate - && prev.animationHandlers?.onAnimationStart === next.animationHandlers?.onAnimationStart - && prev.animationHandlers?.onReservationCancelled === next.animationHandlers?.onReservationCancelled - && prev.animationHandlers?.onReasoningBlock === next.animationHandlers?.onReasoningBlock - && prev.animationHandlers?.onAnimatedHeightChange === next.animationHandlers?.onAnimatedHeightChange; + && prev.reviewTransferDirection === next.reviewTransferDirection; }); MessageRow.displayName = 'MessageRow'; @@ -449,8 +432,6 @@ interface TurnBlockProps { turnUiStates: Map; onToggleTurnGroup: (turnId: string) => void; chatRenderMode: 'sorted' | 'live'; - onMessageContentChange: (reason?: ContentChangeReason) => void; - getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: () => void; stickyUserHeader?: boolean; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; @@ -469,8 +450,6 @@ const TurnBlock = React.memo(({ turnUiStates, onToggleTurnGroup, chatRenderMode, - onMessageContentChange, - getAnimationHandlers, scrollToBottom, stickyUserHeader = true, shouldAnimateUserMessage, @@ -708,19 +687,15 @@ const TurnBlock = React.memo(({ reviewTransferDirection={reviewTransferDirection} animateUserOnMount={shouldAnimateUserMessage(message)} onUserAnimationConsumed={onUserAnimationConsumed} - onContentChange={onMessageContentChange} - animationHandlers={getAnimationHandlers(message.info.id)} scrollToBottom={scrollToBottom} /> ); }, [ - getAnimationHandlers, isLastTurn, nextEntryFirstMessage, messageOrder.lookup, messageOrder.ordered, - onMessageContentChange, scrollToBottom, sessionIsWorking, chatRenderMode, @@ -769,8 +744,6 @@ interface UngroupedMessageRowProps { message: ChatMessageEntry; previousMessage?: ChatMessageEntry; nextMessage?: ChatMessageEntry; - onMessageContentChange: (reason?: ContentChangeReason) => void; - getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: () => void; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; @@ -783,8 +756,6 @@ const UngroupedMessageRow = React.memo(({ message, previousMessage, nextMessage, - onMessageContentChange, - getAnimationHandlers, scrollToBottom, shouldAnimateUserMessage, onUserAnimationConsumed, @@ -799,8 +770,6 @@ const UngroupedMessageRow = React.memo(({ nextMessage={nextMessage} animateUserOnMount={shouldAnimateUserMessage(message)} onUserAnimationConsumed={onUserAnimationConsumed} - onContentChange={onMessageContentChange} - animationHandlers={getAnimationHandlers(message.info.id)} scrollToBottom={scrollToBottom} isInActiveTurn={Boolean(activeStreamingMessageId) && message.info.id === activeStreamingMessageId} activeStreamingPhase={message.info.id === activeStreamingMessageId ? activeStreamingPhase : null} @@ -813,8 +782,6 @@ UngroupedMessageRow.displayName = 'UngroupedMessageRow'; interface MessageListEntryProps { entry: RenderEntry; - onMessageContentChange: (reason?: ContentChangeReason) => void; - getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: () => void; stickyUserHeader?: boolean; sessionIsWorking: boolean; @@ -843,8 +810,6 @@ const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | unde const MessageListEntry = React.memo(({ entry, - onMessageContentChange, - getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, @@ -865,8 +830,6 @@ const MessageListEntry = React.memo(({ message={entry.message} previousMessage={entry.previousMessage} nextMessage={entry.nextMessage} - onMessageContentChange={onMessageContentChange} - getAnimationHandlers={getAnimationHandlers} scrollToBottom={scrollToBottom} shouldAnimateUserMessage={shouldAnimateUserMessage} onUserAnimationConsumed={onUserAnimationConsumed} @@ -892,8 +855,6 @@ const MessageListEntry = React.memo(({ activeStreamingMessageId={activeStreamingMessageId} activeStreamingPhase={activeStreamingPhase} reviewTransferDirection={reviewTransferDirection} - onMessageContentChange={onMessageContentChange} - getAnimationHandlers={getAnimationHandlers} scrollToBottom={scrollToBottom} stickyUserHeader={stickyUserHeader} /> @@ -906,8 +867,6 @@ MessageListEntry.displayName = 'MessageListEntry'; // `renderItem` so the render callback keeps a stable identity — a changing // `renderItem` makes the list re-render every mounted row on every commit. type TimelineRowContextValue = { - onMessageContentChange: (reason?: ContentChangeReason) => void; - getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: () => void; stickyUserHeader: boolean; defaultActivityExpanded: boolean; @@ -938,8 +897,6 @@ const TimelineRow = React.memo(({ entry }: { entry: RenderEntry }) => { { return ( void; - getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: () => void; stickyUserHeader: boolean; sessionIsWorking: boolean; @@ -1110,8 +1063,6 @@ const StreamingTailContent: React.FC<{ }> = ({ entry, directory, - onMessageContentChange, - getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, @@ -1146,8 +1097,6 @@ const StreamingTailContent: React.FC<{ return ( (({ activeStreamingMessageId = null, activeStreamingPhase = null, retryOverlay = null, - onMessageContentChange, - getAnimationHandlers, scrollToBottom, directory, registerList, @@ -1204,7 +1151,6 @@ const MessageList = React.forwardRef(({ previousOrder: string[]; animatedIds: Set; }>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() }); - const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers); const stableScrollToBottom = useStableEvent(() => { scrollToBottom?.(); }); @@ -1421,10 +1367,6 @@ const MessageList = React.forwardRef(({ return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries; }, [historyEntries, trailingStreamingEntry]); - const stableHistoryContentChange = useStableEvent((reason?: ContentChangeReason) => { - onMessageContentChange(reason); - }); - // Stable identities: these reach the list, where a changing callback would // re-render every mounted row. const stableIsAtEndChange = useStableEvent((isAtEnd: boolean) => { @@ -1763,8 +1705,6 @@ const MessageList = React.forwardRef(({ }, [allEntries, anchorMessageId, onAnchorReady, onAnchorSizeChanged]); const rowContext = React.useMemo(() => ({ - onMessageContentChange: stableHistoryContentChange, - getAnimationHandlers: stableGetAnimationHandlers, scrollToBottom: stableScrollToBottom, stickyUserHeader, defaultActivityExpanded, @@ -1791,8 +1731,6 @@ const MessageList = React.forwardRef(({ sessionIsWorking, shouldAnimateUserMessage, showTurnChangedFiles, - stableGetAnimationHandlers, - stableHistoryContentChange, stableScrollToBottom, stickyUserHeader, toggleTurnGroup, diff --git a/packages/ui/src/components/chat/btw/BtwPanel.tsx b/packages/ui/src/components/chat/btw/BtwPanel.tsx index 54c55c6d..3c8b37e2 100644 --- a/packages/ui/src/components/chat/btw/BtwPanel.tsx +++ b/packages/ui/src/components/chat/btw/BtwPanel.tsx @@ -26,9 +26,6 @@ import { QuestionCard } from '../QuestionCard'; const IDLE_SESSION_STATUS = { type: 'idle' as const }; -/** Stable no-op so ChatMessage memoization keeps working in the read-only peek. */ -const NOOP_CONTENT_CHANGE = (): void => {}; - /** * The `/btw` peek panel. * @@ -446,7 +443,6 @@ const BtwMessages: React.FC<{ message={record} previousMessage={data.messageRecords[index - 1]} nextMessage={data.messageRecords[index + 1]} - onContentChange={NOOP_CONTENT_CHANGE} isInActiveTurn={index === data.messageRecords.length - 1} activeStreamingPhase={ record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null diff --git a/packages/ui/src/components/chat/components/TurnActivity.tsx b/packages/ui/src/components/chat/components/TurnActivity.tsx index f9fac0b6..4b49b91e 100644 --- a/packages/ui/src/components/chat/components/TurnActivity.tsx +++ b/packages/ui/src/components/chat/components/TurnActivity.tsx @@ -4,7 +4,6 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup'; import type { TurnActivityRecord } from '../lib/turns/types'; import type { ToolPopupContent } from '../message/types'; import type { StreamPhase } from '../message/types'; -import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll'; interface DiffStats { additions: number; @@ -21,7 +20,6 @@ interface TurnActivityProps { expandedTools: Set; onToggleTool: (toolId: string) => void; onShowPopup: (content: ToolPopupContent) => void; - onContentChange?: (reason?: ContentChangeReason) => void; streamPhase: StreamPhase; showHeader: boolean; animateRows?: boolean; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 73f2a6a1..35a17a4d 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -19,7 +19,6 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; -import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll'; import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -419,13 +418,10 @@ interface MessageBodyProps { onShowPopup: (content: ToolPopupContent) => void; streamPhase: StreamPhase; allowAnimation: boolean; - onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void; - shouldShowHeader?: boolean; hasTextContent?: boolean; onCopyMessage?: () => void | boolean | Promise; copiedMessage?: boolean; - onAuxiliaryContentComplete?: () => void; showReasoningTraces?: boolean; agentMention?: AgentMentionInfo; turnGroupingContext?: TurnGroupingContext; @@ -1112,10 +1108,8 @@ const AssistantMessageBody = React.memo(({ onShowPopup, streamPhase: _streamPhase, allowAnimation: _allowAnimation, - onContentChange, hasTextContent = false, onCopyMessage, - onAuxiliaryContentComplete, showReasoningTraces = false, turnGroupingContext, errorMessage, @@ -1423,50 +1417,6 @@ const AssistantMessageBody = React.memo(({ || (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized)); const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning; - const hasAuxiliaryContent = hasTools || reasoningParts.length > 0; - const isTextlessAssistantMessage = assistantTextParts.length === 0; - const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete; - const auxiliaryCompletionAnnouncedRef = React.useRef(false); - const soloReasoningScrollTriggeredRef = React.useRef(false); - - React.useEffect(() => { - soloReasoningScrollTriggeredRef.current = false; - }, [messageId]); - - React.useEffect(() => { - if (!auxiliaryContentComplete) { - auxiliaryCompletionAnnouncedRef.current = false; - return; - } - if (auxiliaryCompletionAnnouncedRef.current) { - return; - } - auxiliaryCompletionAnnouncedRef.current = true; - onAuxiliaryContentComplete?.(); - }, [auxiliaryContentComplete, onAuxiliaryContentComplete]); - - React.useEffect(() => { - if (awaitingMessageCompletion) { - soloReasoningScrollTriggeredRef.current = false; - return; - } - if (hasTools) { - soloReasoningScrollTriggeredRef.current = false; - return; - } - if (reasoningParts.length === 0) { - return; - } - if (shouldHoldReasoning || !reasoningComplete) { - return; - } - if (soloReasoningScrollTriggeredRef.current) { - return; - } - soloReasoningScrollTriggeredRef.current = true; - onContentChange?.('structural'); - }, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]); - const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion; const handleForkClick = React.useCallback( @@ -1821,7 +1771,6 @@ const AssistantMessageBody = React.memo(({ expandedTools={expandedTools} onToggleTool={onToggleTool} onShowPopup={onShowPopup} - onContentChange={onContentChange} streamPhase={effectiveStreamPhase} showHeader={true} animateRows={animateActivityRows} @@ -1898,7 +1847,6 @@ const AssistantMessageBody = React.memo(({ messageId={messageId} streamPhase={effectiveStreamPhase} chatRenderMode={chatRenderMode} - onContentChange={onContentChange} onShowPopup={onShowPopup} /> @@ -1933,7 +1881,6 @@ const AssistantMessageBody = React.memo(({ messageId={messageId} streamPhase={effectiveStreamPhase} chatRenderMode={chatRenderMode} - onContentChange={onContentChange} onShowPopup={onShowPopup} /> ); @@ -1945,7 +1892,6 @@ const AssistantMessageBody = React.memo(({ part={part} messageId={messageId} streamPhase={effectiveStreamPhase} - onContentChange={onContentChange} /> ); } @@ -1989,7 +1935,6 @@ const AssistantMessageBody = React.memo(({ onToggle={onToggleTool} isMobile={isMobile} alwaysShowActions={alwaysShowMessageActions} - onContentChange={onContentChange} onShowPopup={onShowPopup} animateTailText={animatedToolIdsLookup.has(toolPart.id)} /> @@ -2061,7 +2006,6 @@ const AssistantMessageBody = React.memo(({ messageActionButtons, renderJustificationActions, sessionId, - onContentChange, onShowPopup, onToggleTool, shouldRenderActivityGroup, diff --git a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx index 73095a98..45720bfe 100644 --- a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx @@ -2,7 +2,6 @@ import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; import { MarkdownRenderer } from '../../MarkdownRenderer'; import type { StreamPhase, ToolPopupContent } from '../types'; -import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility'; import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug'; @@ -17,7 +16,6 @@ interface AssistantTextPartProps { messageId: string; streamPhase: StreamPhase; chatRenderMode?: 'sorted' | 'live'; - onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void; onShowPopup?: (content: ToolPopupContent) => void; } diff --git a/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx b/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx index 99c08103..1008694d 100644 --- a/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx @@ -1,6 +1,5 @@ import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; -import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll'; import { useUIStore } from '@/stores/useUIStore'; import { ReasoningTimelineBlock } from './ReasoningPart'; @@ -22,14 +21,12 @@ const cleanJustificationText = (text: string): string => { interface JustificationBlockProps { part: Part; messageId: string; - onContentChange?: (reason?: ContentChangeReason) => void; actions?: React.ReactNode; } const JustificationBlock: React.FC = ({ part, messageId, - onContentChange, actions, }) => { const chatRenderMode = useUIStore((state) => state.chatRenderMode); @@ -47,7 +44,6 @@ const JustificationBlock: React.FC = ({ ; onToggleTool: (toolId: string) => void; onShowPopup: (content: ToolPopupContent) => void; - onContentChange?: (reason?: ContentChangeReason) => void; streamPhase: StreamPhase; showHeader: boolean; animateRows?: boolean; @@ -376,7 +374,6 @@ interface ExpandableToolRowProps { isMobile: boolean; onToggleTool: (toolId: string) => void; onShowPopup: (content: ToolPopupContent) => void; - onContentChange?: (reason?: ContentChangeReason) => void; animateTailText: boolean; } @@ -386,7 +383,6 @@ const ExpandableToolRow: React.FC = ({ isMobile, onToggleTool, onShowPopup, - onContentChange, animateTailText, }) => { const handleToggle = React.useCallback(() => { @@ -399,7 +395,6 @@ const ExpandableToolRow: React.FC = ({ isExpanded={isExpanded} onToggle={handleToggle} isMobile={isMobile} - onContentChange={onContentChange} onShowPopup={onShowPopup} animateTailText={animateTailText} /> @@ -423,7 +418,6 @@ const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => { && prev.isMobile === next.isMobile && prev.onToggleTool === next.onToggleTool && prev.onShowPopup === next.onShowPopup - && prev.onContentChange === next.onContentChange && prev.animateTailText === next.animateTailText && prev.activity.id === next.activity.id && prev.activity.kind === next.activity.kind @@ -789,9 +783,8 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => { /** * Inline reasoning text block — rendered as dimmed italic markdown. */ -const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhase }: { +const InlineReasoningBlock = React.memo(({ activity, streamPhase }: { activity: TurnActivityPart; - onContentChange?: (reason?: ContentChangeReason) => void; streamPhase: StreamPhase; }) => { return ( @@ -799,7 +792,6 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas part={activity.part} messageId={activity.messageId} streamPhase={streamPhase} - onContentChange={onContentChange} /> ); }); @@ -807,16 +799,14 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas /** * Inline justification text block — rendered as normal assistant text between tools. */ -const InlineJustificationBlock = React.memo(({ activity, onContentChange, actions }: { +const InlineJustificationBlock = React.memo(({ activity, actions }: { activity: TurnActivityPart; - onContentChange?: (reason?: ContentChangeReason) => void; actions?: React.ReactNode; }) => { return ( ); @@ -831,7 +821,6 @@ const ProgressiveGroup: React.FC = ({ expandedTools, onToggleTool, onShowPopup, - onContentChange, streamPhase, showHeader, animateRows = true, @@ -892,7 +881,6 @@ const ProgressiveGroup: React.FC = ({ ); @@ -903,7 +891,6 @@ const ProgressiveGroup: React.FC = ({ <> @@ -918,7 +905,6 @@ const ProgressiveGroup: React.FC = ({ isMobile={isMobile} onToggleTool={onToggleTool} onShowPopup={onShowPopup} - onContentChange={onContentChange} animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} /> ); @@ -942,7 +928,6 @@ const ProgressiveGroup: React.FC = ({ isMobile={isMobile} onToggleTool={onToggleTool} onShowPopup={onShowPopup} - onContentChange={onContentChange} animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} /> ); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index ad091e38..1276687a 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { animate, type AnimationPlaybackControls } from 'motion'; import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; -import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Icon } from '@/components/icon/Icon'; import { BusyDots } from './BusyDots'; @@ -81,7 +80,6 @@ const getReasoningSummary = (text: string): string => { type ReasoningTimelineBlockProps = { text: string; variant: ReasoningVariant; - onContentChange?: (reason?: ContentChangeReason) => void; blockId: string; time?: { start?: number; end?: number }; showDuration?: boolean; @@ -99,7 +97,6 @@ type ExpansionState = { export const ReasoningTimelineBlock: React.FC = ({ text, variant, - onContentChange, blockId, time, isStreaming = false, @@ -123,11 +120,6 @@ export const ReasoningTimelineBlock: React.FC = ({ const contentRef = React.useRef(null); const contentAnimationRef = React.useRef(null); const contentMountedRef = React.useRef(false); - // Stable handle to onContentChange so the height-animation layout effect can - // signal auto-follow without taking onContentChange as a dependency (which - // would risk re-running — and thus restarting — the animation on re-render). - const onContentChangeRef = React.useRef(onContentChange); - onContentChangeRef.current = onContentChange; const summary = React.useMemo(() => getReasoningSummary(text), [text]); const toggleAriaLabel = isExpanded @@ -137,8 +129,7 @@ export const ReasoningTimelineBlock: React.FC = ({ const handleToggle = React.useCallback(() => { setShouldRenderExpandedContent(true); setExpansion({ expanded: !isExpanded, source: 'user' }); - onContentChange?.('structural'); - }, [isExpanded, onContentChange]); + }, [isExpanded]); const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { @@ -159,13 +150,6 @@ export const ReasoningTimelineBlock: React.FC = ({ }); }, [canAutoExpand]); - React.useEffect(() => { - if (text.trim().length === 0) { - return; - } - onContentChange?.('structural'); - }, [onContentChange, text]); - React.useEffect(() => { if (isExpanded || isStreaming) { setShouldRenderExpandedContent(true); @@ -239,11 +223,6 @@ export const ReasoningTimelineBlock: React.FC = ({ element.style.height = '0px'; } else { element.style.height = `${element.scrollHeight}px`; - // Only the COLLAPSE animation needs the guard: it shrinks the - // timeline and the trailing async scroll events can be misread as a - // user scroll-away. Expansion grows the timeline and re-pins cleanly, - // and guarding it caused a faint scroll fight while thinking streams. - onContentChangeRef.current?.('animation'); } const animation = animate( @@ -436,14 +415,12 @@ export const ReasoningTimelineBlock: React.FC = ({ type ReasoningPartProps = { part: Part; - onContentChange?: (reason?: ContentChangeReason) => void; messageId: string; streamPhase?: StreamPhase; }; const ReasoningPart = React.memo(({ part, - onContentChange, messageId, streamPhase, }: ReasoningPartProps) => { @@ -470,7 +447,6 @@ const ReasoningPart = React.memo(({ void; isMobile: boolean; alwaysShowActions?: boolean; - onContentChange?: (reason?: ContentChangeReason) => void; onShowPopup?: (content: ToolPopupContent) => void; animateTailText?: boolean; } @@ -1684,7 +1682,6 @@ const ToolPartContent: React.FC = ({ isExpanded, onToggle, isMobile, - onContentChange, onShowPopup, animateTailText = true, }) => { @@ -1754,10 +1751,6 @@ const ToolPartContent: React.FC = ({ }); }, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]); - const shouldNotifyStructuralChange = isFinalized || isTaskTool; - - const onContentChangeRef = React.useRef(onContentChange); - onContentChangeRef.current = onContentChange; const expandedContentRef = React.useRef(null); React.useLayoutEffect(() => { @@ -1772,11 +1765,7 @@ const ToolPartContent: React.FC = ({ element.style.height = isExpanded ? 'auto' : '0px'; element.style.overflow = isExpanded ? 'visible' : 'hidden'; - - if (shouldNotifyStructuralChange) { - onContentChangeRef.current?.('structural'); - } - }, [isExpanded, isTaskTool, shouldNotifyStructuralChange]); + }, [isExpanded, isTaskTool]); const partMetadata = (part as unknown as { metadata?: unknown }).metadata; const time = stateWithData.time; @@ -1934,26 +1923,6 @@ const ToolPartContent: React.FC = ({ } return metadataTaskSummaryEntries; }, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]); - const taskSummaryRenderSignature = React.useMemo(() => { - return taskSummaryEntries.map(getTaskSummaryEntryRenderSignature).join('\u0000'); - }, [taskSummaryEntries]); - const lastTaskSummaryRenderSignatureRef = React.useRef(null); - - React.useEffect(() => { - if (!isTaskTool) { - lastTaskSummaryRenderSignatureRef.current = null; - return; - } - - const previous = lastTaskSummaryRenderSignatureRef.current; - lastTaskSummaryRenderSignatureRef.current = taskSummaryRenderSignature; - if (previous === null || previous === taskSummaryRenderSignature || taskSummaryEntries.length === 0) { - return; - } - - onContentChangeRef.current?.('structural'); - }, [isTaskTool, taskSummaryEntries.length, taskSummaryRenderSignature]); - const diffStats = React.useMemo(() => { return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch') ? parseDiffStats(metadata) @@ -2351,7 +2320,6 @@ export default React.memo(ToolPart, (prev, next) => { && prev.isExpanded === next.isExpanded && prev.isMobile === next.isMobile && prev.alwaysShowActions === next.alwaysShowActions - && prev.onContentChange === next.onContentChange && prev.onShowPopup === next.onShowPopup && prev.animateTailText === next.animateTailText; }); diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index edae356c..364640de 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -37,22 +37,6 @@ import { // guard/settle/entry-stick timers here. // ────────────────────────────────────────────────────────────────────────── -// Kept for source compatibility with message parts that report content growth. -// Growth no longer drives scrolling — the list handles it — so these are inert, -// but the prop threads through many part components and removing the contract -// is a separate change. -export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation'; - -export interface AnimationHandlers { - onChunk: () => void; - onComplete: () => void; - onStreamingCandidate?: () => void; - onAnimationStart?: () => void; - onReservationCancelled?: () => void; - onReasoningBlock?: () => void; - onAnimatedHeightChange?: (height: number) => void; -} - // The subset of the list ref this hook drives. Declared structurally so the // hook stays testable without a renderer and does not hard-depend on the list // implementation. @@ -105,8 +89,6 @@ export interface UseChatTimelineScrollResult { isFollowingProgrammatically: boolean; goToBottom: (mode?: 'instant' | 'smooth') => void; scrollToBottomOnSend: () => void; - notifyContentChange: (reason?: ContentChangeReason) => void; - getAnimationHandlers: (messageId: string) => AnimationHandlers; saveSnapshotNow: () => void; restoreSnapshot: () => Promise; } @@ -125,8 +107,6 @@ const ANCHOR_POSITION_ATTEMPTS = 12; // a genuine relayout and must not be undone. const ANCHOR_RESTORE_TOLERANCE_PX = 2; -const NOOP = (): void => {}; - export const useChatTimelineScroll = ({ currentSessionId, currentSessionKey, @@ -790,18 +770,6 @@ export const useChatTimelineScroll = ({ }; }, [onActiveTurnChange, scrollNode]); - // ── inert compatibility surface ───────────────────────────────────────── - const stableAnimationHandlers = React.useMemo(() => ({ - onChunk: NOOP, - onComplete: NOOP, - onStreamingCandidate: NOOP, - onAnimationStart: NOOP, - onReservationCancelled: NOOP, - onReasoningBlock: NOOP, - onAnimatedHeightChange: NOOP, - }), []); - const getAnimationHandlers = React.useCallback(() => stableAnimationHandlers, [stableAnimationHandlers]); - return { scrollRef, scrollNode, @@ -818,8 +786,6 @@ export const useChatTimelineScroll = ({ isFollowingProgrammatically, goToBottom, scrollToBottomOnSend, - notifyContentChange: NOOP, - getAnimationHandlers, saveSnapshotNow, restoreSnapshot, };