refactor(chat): drop the inert content-change and animation-handler contract

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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 15:01:55 +03:00
parent dac31ee026
commit 34ae8b059e
12 changed files with 6 additions and 401 deletions
@@ -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<ChatContainerProps> = ({
onIsAtEndChange,
onManualNavigation,
onTimelineDataChange,
notifyContentChange: handleMessageContentChange,
getAnimationHandlers,
goToBottom,
scrollToBottomOnSend,
restoreSnapshot,
@@ -1026,13 +1016,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
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<ChatContainerProps> = ({
streamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
handleMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={resumeToLatestInstant}
sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions}
@@ -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<ChatMessageProps> = ({
message,
previousMessage,
nextMessage,
onContentChange,
animationHandlers,
turnGroupingContext,
assistantHeaderMessageId,
isInActiveTurn = false,
@@ -850,35 +845,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
});
}, [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<ChatMessageProps> = ({
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<ChatMessageProps> = ({
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<ChatMessageProps> = ({
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<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={shouldShowHeader}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
showReasoningTraces={showReasoningTraces}
agentMention={agentMention}
turnGroupingContext={turnGroupingContext}
@@ -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<MessageRowProps>(({
activeStreamingPhase,
animateUserOnMount,
onUserAnimationConsumed,
onContentChange,
animationHandlers,
scrollToBottom,
reviewTransferDirection,
}) => {
@@ -403,8 +396,6 @@ const MessageRow = React.memo<MessageRowProps>(({
nextMessage={nextMessage}
animateUserOnMount={animateUserOnMount}
onUserAnimationConsumed={onUserAnimationConsumed}
onContentChange={onContentChange}
animationHandlers={animationHandlers}
scrollToBottom={scrollToBottom}
turnGroupingContext={turnGroupingContext}
assistantHeaderMessageId={assistantHeaderMessageId}
@@ -422,20 +413,12 @@ const MessageRow = React.memo<MessageRowProps>(({
&& 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<string, TurnUiState>;
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 }) => {
<StreamingTailContent
entry={entry}
directory={context.directory}
onMessageContentChange={context.onMessageContentChange}
getAnimationHandlers={context.getAnimationHandlers}
scrollToBottom={context.scrollToBottom}
stickyUserHeader={context.stickyUserHeader}
sessionIsWorking={context.sessionIsWorking}
@@ -960,8 +917,6 @@ const TimelineRow = React.memo(({ entry }: { entry: RenderEntry }) => {
return (
<MessageListEntry
entry={entry}
onMessageContentChange={context.onMessageContentChange}
getAnimationHandlers={context.getAnimationHandlers}
scrollToBottom={context.scrollToBottom}
stickyUserHeader={context.stickyUserHeader}
sessionIsWorking={false}
@@ -1092,8 +1047,6 @@ TimelineList.displayName = 'TimelineList';
const StreamingTailContent: React.FC<{
entry: RenderEntry;
directory?: string;
onMessageContentChange: (reason?: ContentChangeReason) => 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 (
<MessageListEntry
entry={liveEntry}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
stickyUserHeader={stickyUserHeader}
sessionIsWorking={sessionIsWorking}
@@ -1173,8 +1122,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
activeStreamingMessageId = null,
activeStreamingPhase = null,
retryOverlay = null,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
directory,
registerList,
@@ -1204,7 +1151,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
previousOrder: string[];
animatedIds: Set<string>;
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
const stableScrollToBottom = useStableEvent(() => {
scrollToBottom?.();
});
@@ -1421,10 +1367,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
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<MessageListHandle, MessageListProps>(({
}, [allEntries, anchorMessageId, onAnchorReady, onAnchorSizeChanged]);
const rowContext = React.useMemo(() => ({
onMessageContentChange: stableHistoryContentChange,
getAnimationHandlers: stableGetAnimationHandlers,
scrollToBottom: stableScrollToBottom,
stickyUserHeader,
defaultActivityExpanded,
@@ -1791,8 +1731,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
sessionIsWorking,
shouldAnimateUserMessage,
showTurnChangedFiles,
stableGetAnimationHandlers,
stableHistoryContentChange,
stableScrollToBottom,
stickyUserHeader,
toggleTurnGroup,
@@ -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
@@ -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<string>;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
showHeader: boolean;
animateRows?: boolean;
@@ -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<void | boolean>;
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}
/>
</div>
@@ -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,
@@ -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;
}
@@ -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<JustificationBlockProps> = ({
part,
messageId,
onContentChange,
actions,
}) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
@@ -47,7 +44,6 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
<ReasoningTimelineBlock
text={textContent}
variant="justification"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-justification`}
time={time}
showDuration={chatRenderMode !== 'sorted'}
@@ -4,7 +4,6 @@ import { cn } from '@/lib/utils';
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
import type { ToolPopupContent } from '../types';
import ToolPart from './ToolPart';
import { MinDurationShineText } from './MinDurationShineText';
@@ -40,7 +39,6 @@ interface ProgressiveGroupProps {
expandedTools: Set<string>;
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<ExpandableToolRowProps> = ({
isMobile,
onToggleTool,
onShowPopup,
onContentChange,
animateTailText,
}) => {
const handleToggle = React.useCallback(() => {
@@ -399,7 +395,6 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
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 (
<JustificationBlock
part={activity.part}
messageId={activity.messageId}
onContentChange={onContentChange}
actions={actions}
/>
);
@@ -831,7 +821,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
expandedTools,
onToggleTool,
onShowPopup,
onContentChange,
streamPhase,
showHeader,
animateRows = true,
@@ -892,7 +881,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<InlineReasoningBlock
activity={row.activity}
streamPhase={streamPhase}
onContentChange={onContentChange}
/>
</>
);
@@ -903,7 +891,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<>
<InlineJustificationBlock
activity={row.activity}
onContentChange={onContentChange}
actions={renderJustificationActions?.(row.activity)}
/>
</>
@@ -918,7 +905,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile={isMobile}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
/>
);
@@ -942,7 +928,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile={isMobile}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
/>
);
@@ -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<ReasoningTimelineBlockProps> = ({
text,
variant,
onContentChange,
blockId,
time,
isStreaming = false,
@@ -123,11 +120,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const contentRef = React.useRef<HTMLDivElement>(null);
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(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<ReasoningTimelineBlockProps> = ({
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<ReasoningTimelineBlockProps> = ({
});
}, [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<ReasoningTimelineBlockProps> = ({
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<ReasoningTimelineBlockProps> = ({
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(({
<ReasoningTimelineBlock
text={throttledText}
variant="thinking"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning`}
time={time}
isStreaming={isStreaming}
@@ -20,7 +20,6 @@ import { toast } from '@/components/ui';
import { Text } from '@/components/ui/text';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
import type { ToolPopupContent } from '../types';
import { PlainDiffFallback } from './PlainDiffFallback';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -82,7 +81,6 @@ interface ToolPartProps {
onToggle: (toolId: string) => void;
isMobile: boolean;
alwaysShowActions?: boolean;
onContentChange?: (reason?: ContentChangeReason) => void;
onShowPopup?: (content: ToolPopupContent) => void;
animateTailText?: boolean;
}
@@ -1684,7 +1682,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
isExpanded,
onToggle,
isMobile,
onContentChange,
onShowPopup,
animateTailText = true,
}) => {
@@ -1754,10 +1751,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
});
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange;
const expandedContentRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => {
@@ -1772,11 +1765,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
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<ToolPartProps> = ({
}
return metadataTaskSummaryEntries;
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
const taskSummaryRenderSignature = React.useMemo(() => {
return taskSummaryEntries.map(getTaskSummaryEntryRenderSignature).join('\u0000');
}, [taskSummaryEntries]);
const lastTaskSummaryRenderSignatureRef = React.useRef<string | null>(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;
});
@@ -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<boolean>;
}
@@ -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<AnimationHandlers>(() => ({
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,
};