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 ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail'; import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { useScrollShadow } from '@/components/ui/useScrollShadow'; 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 { useChatTimelineController } from './hooks/useChatTimelineController';
import { TimelineDialog } from './TimelineDialog'; import { TimelineDialog } from './TimelineDialog';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
@@ -172,8 +172,6 @@ type ChatViewportProps = {
confirmedAt?: number; confirmedAt?: number;
fallbackTimestamp?: number; fallbackTimestamp?: number;
} | null; } | null;
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom: () => void; scrollToBottom: () => void;
sessionQuestions: QuestionRequest[]; sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[]; sessionPermissions: PermissionRequest[];
@@ -210,8 +208,6 @@ const ChatViewport = React.memo(({
streamingMessageId, streamingMessageId,
activeStreamingPhase, activeStreamingPhase,
retryOverlay, retryOverlay,
handleMessageContentChange,
getAnimationHandlers,
scrollToBottom, scrollToBottom,
sessionQuestions, sessionQuestions,
sessionPermissions, sessionPermissions,
@@ -394,8 +390,6 @@ const ChatViewport = React.memo(({
activeStreamingMessageId={streamingMessageId} activeStreamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase} activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay} retryOverlay={retryOverlay}
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
isLoadingOlder={isLoadingOlder} isLoadingOlder={isLoadingOlder}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
directory={directory} directory={directory}
@@ -443,8 +437,6 @@ const ChatViewport = React.memo(({
&& prev.streamingMessageId === next.streamingMessageId && prev.streamingMessageId === next.streamingMessageId
&& prev.activeStreamingPhase === next.activeStreamingPhase && prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.retryOverlay === next.retryOverlay && prev.retryOverlay === next.retryOverlay
&& prev.handleMessageContentChange === next.handleMessageContentChange
&& prev.getAnimationHandlers === next.getAnimationHandlers
&& prev.scrollToBottom === next.scrollToBottom && prev.scrollToBottom === next.scrollToBottom
&& prev.sessionQuestions === next.sessionQuestions && prev.sessionQuestions === next.sessionQuestions
&& prev.sessionPermissions === next.sessionPermissions && prev.sessionPermissions === next.sessionPermissions
@@ -959,8 +951,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
onIsAtEndChange, onIsAtEndChange,
onManualNavigation, onManualNavigation,
onTimelineDataChange, onTimelineDataChange,
notifyContentChange: handleMessageContentChange,
getAnimationHandlers,
goToBottom, goToBottom,
scrollToBottomOnSend, scrollToBottomOnSend,
restoreSnapshot, restoreSnapshot,
@@ -1026,13 +1016,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
activeTurnChangeRef.current = timelineController.handleActiveTurnChange; activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
}, [timelineController.handleActiveTurnChange]); }, [timelineController.handleActiveTurnChange]);
React.useEffect(() => {
if (sessionPermissions.length === 0 && sessionQuestions.length === 0) {
return;
}
handleMessageContentChange('permission');
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
const navigation = useChatTurnNavigation({ const navigation = useChatTurnNavigation({
sessionId: currentSessionId, sessionId: currentSessionId,
turnIds: timelineController.turnIds, turnIds: timelineController.turnIds,
@@ -1365,8 +1348,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
streamingMessageId={streamingMessageId} streamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase} activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay} retryOverlay={retryOverlay}
handleMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={resumeToLatestInstant} scrollToBottom={resumeToLatestInstant}
sessionQuestions={sessionQuestions} sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions} sessionPermissions={sessionPermissions}
@@ -14,7 +14,6 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useChatSurfaceMode } from './useChatSurfaceMode'; import { useChatSurfaceMode } from './useChatSurfaceMode';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatTimelineScroll';
import MessageBody from './message/MessageBody'; import MessageBody from './message/MessageBody';
import type { AgentMentionInfo } from './message/types'; import type { AgentMentionInfo } from './message/types';
import type { StreamPhase, ToolPopupContent } from './message/types'; import type { StreamPhase, ToolPopupContent } from './message/types';
@@ -132,8 +131,6 @@ interface ChatMessageProps {
info: Message; info: Message;
parts: Part[]; parts: Part[];
}; };
onContentChange?: (reason?: ContentChangeReason) => void;
animationHandlers?: AnimationHandlers;
scrollToBottom?: () => void; scrollToBottom?: () => void;
turnGroupingContext?: TurnGroupingContext; turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string; assistantHeaderMessageId?: string;
@@ -148,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
message, message,
previousMessage, previousMessage,
nextMessage, nextMessage,
onContentChange,
animationHandlers,
turnGroupingContext, turnGroupingContext,
assistantHeaderMessageId, assistantHeaderMessageId,
isInActiveTurn = false, isInActiveTurn = false,
@@ -850,35 +845,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}); });
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]); }, [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); const hasEverStreamedRef = React.useRef(false);
React.useEffect(() => { React.useEffect(() => {
animationCompletedRef.current = false;
hasRequestedReservationRef.current = false;
animationStartNotifiedRef.current = false;
hasTriggeredReservationOnceRef.current = false;
hasAnnouncedAuxiliaryScrollRef.current = false;
hasEverStreamedRef.current = false; hasEverStreamedRef.current = false;
}, [message.info.id]); }, [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 setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
const handleShowPopup = React.useCallback((content: ToolPopupContent) => { const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
@@ -901,114 +873,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
hasEverStreamedRef.current = true; 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 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) { if (shouldHideUserMessage) {
return null; return null;
@@ -1070,13 +935,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup} onShowPopup={handleShowPopup}
streamPhase={streamPhase} streamPhase={streamPhase}
allowAnimation={allowAnimation} allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false} shouldShowHeader={false}
hasTextContent={hasTextContent} hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage} onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage} copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces} showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention} agentMention={agentMention}
onRevert={handleRevert} onRevert={handleRevert}
onFork={isUser ? handleFork : undefined} onFork={isUser ? handleFork : undefined}
@@ -1106,13 +969,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup} onShowPopup={handleShowPopup}
streamPhase={streamPhase} streamPhase={streamPhase}
allowAnimation={allowAnimation} allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false} shouldShowHeader={false}
hasTextContent={hasTextContent} hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage} onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage} copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces} showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention} agentMention={agentMention}
onRevert={handleRevert} onRevert={handleRevert}
onFork={isUser ? handleFork : undefined} onFork={isUser ? handleFork : undefined}
@@ -1152,12 +1013,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup} onShowPopup={handleShowPopup}
streamPhase={streamPhase} streamPhase={streamPhase}
allowAnimation={allowAnimation} allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={shouldShowHeader} shouldShowHeader={shouldShowHeader}
hasTextContent={hasTextContent} hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage} onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage} copiedMessage={copiedMessage}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
showReasoningTraces={showReasoningTraces} showReasoningTraces={showReasoningTraces}
agentMention={agentMention} agentMention={agentMention}
turnGroupingContext={turnGroupingContext} turnGroupingContext={turnGroupingContext}
@@ -5,7 +5,6 @@ import { LegendList, type LegendListRef } from '@legendapp/list/react';
import ChatMessage from './ChatMessage'; import ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
import TurnItem from './components/TurnItem'; import TurnItem from './components/TurnItem';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatTimelineScroll';
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types'; import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
import { useTurnRecords } from './hooks/useTurnRecords'; import { useTurnRecords } from './hooks/useTurnRecords';
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay'; import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
@@ -317,8 +316,6 @@ interface MessageListProps {
confirmedAt?: number; confirmedAt?: number;
fallbackTimestamp?: number; fallbackTimestamp?: number;
} | null; } | null;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
isLoadingOlder: boolean; isLoadingOlder: boolean;
scrollToBottom?: () => void; scrollToBottom?: () => void;
directory?: string; directory?: string;
@@ -375,8 +372,6 @@ interface MessageRowProps {
activeStreamingPhase?: StreamPhase | null; activeStreamingPhase?: StreamPhase | null;
animateUserOnMount?: boolean; animateUserOnMount?: boolean;
onUserAnimationConsumed?: (messageId: string) => void; onUserAnimationConsumed?: (messageId: string) => void;
onContentChange: (reason?: ContentChangeReason) => void;
animationHandlers: AnimationHandlers;
scrollToBottom?: () => void; scrollToBottom?: () => void;
reviewTransferDirection?: ReviewTransferDirection | null; reviewTransferDirection?: ReviewTransferDirection | null;
} }
@@ -391,8 +386,6 @@ const MessageRow = React.memo<MessageRowProps>(({
activeStreamingPhase, activeStreamingPhase,
animateUserOnMount, animateUserOnMount,
onUserAnimationConsumed, onUserAnimationConsumed,
onContentChange,
animationHandlers,
scrollToBottom, scrollToBottom,
reviewTransferDirection, reviewTransferDirection,
}) => { }) => {
@@ -403,8 +396,6 @@ const MessageRow = React.memo<MessageRowProps>(({
nextMessage={nextMessage} nextMessage={nextMessage}
animateUserOnMount={animateUserOnMount} animateUserOnMount={animateUserOnMount}
onUserAnimationConsumed={onUserAnimationConsumed} onUserAnimationConsumed={onUserAnimationConsumed}
onContentChange={onContentChange}
animationHandlers={animationHandlers}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
turnGroupingContext={turnGroupingContext} turnGroupingContext={turnGroupingContext}
assistantHeaderMessageId={assistantHeaderMessageId} assistantHeaderMessageId={assistantHeaderMessageId}
@@ -422,20 +413,12 @@ const MessageRow = React.memo<MessageRowProps>(({
&& areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage) && areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage)
&& prev.animateUserOnMount === next.animateUserOnMount && prev.animateUserOnMount === next.animateUserOnMount
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed && prev.onUserAnimationConsumed === next.onUserAnimationConsumed
&& prev.onContentChange === next.onContentChange
&& prev.scrollToBottom === next.scrollToBottom && prev.scrollToBottom === next.scrollToBottom
&& areRelevantTurnGroupingContextsEqual(prevTurn, nextTurn, prev.message.info.id, resolveMessageRole(prev.message) === 'user') && areRelevantTurnGroupingContextsEqual(prevTurn, nextTurn, prev.message.info.id, resolveMessageRole(prev.message) === 'user')
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId && prev.assistantHeaderMessageId === next.assistantHeaderMessageId
&& prev.isInActiveTurn === next.isInActiveTurn && prev.isInActiveTurn === next.isInActiveTurn
&& prev.activeStreamingPhase === next.activeStreamingPhase && prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.reviewTransferDirection === next.reviewTransferDirection && 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;
}); });
MessageRow.displayName = 'MessageRow'; MessageRow.displayName = 'MessageRow';
@@ -449,8 +432,6 @@ interface TurnBlockProps {
turnUiStates: Map<string, TurnUiState>; turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void; onToggleTurnGroup: (turnId: string) => void;
chatRenderMode: 'sorted' | 'live'; chatRenderMode: 'sorted' | 'live';
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void; scrollToBottom?: () => void;
stickyUserHeader?: boolean; stickyUserHeader?: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
@@ -469,8 +450,6 @@ const TurnBlock = React.memo(({
turnUiStates, turnUiStates,
onToggleTurnGroup, onToggleTurnGroup,
chatRenderMode, chatRenderMode,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom, scrollToBottom,
stickyUserHeader = true, stickyUserHeader = true,
shouldAnimateUserMessage, shouldAnimateUserMessage,
@@ -708,19 +687,15 @@ const TurnBlock = React.memo(({
reviewTransferDirection={reviewTransferDirection} reviewTransferDirection={reviewTransferDirection}
animateUserOnMount={shouldAnimateUserMessage(message)} animateUserOnMount={shouldAnimateUserMessage(message)}
onUserAnimationConsumed={onUserAnimationConsumed} onUserAnimationConsumed={onUserAnimationConsumed}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
/> />
); );
}, },
[ [
getAnimationHandlers,
isLastTurn, isLastTurn,
nextEntryFirstMessage, nextEntryFirstMessage,
messageOrder.lookup, messageOrder.lookup,
messageOrder.ordered, messageOrder.ordered,
onMessageContentChange,
scrollToBottom, scrollToBottom,
sessionIsWorking, sessionIsWorking,
chatRenderMode, chatRenderMode,
@@ -769,8 +744,6 @@ interface UngroupedMessageRowProps {
message: ChatMessageEntry; message: ChatMessageEntry;
previousMessage?: ChatMessageEntry; previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry; nextMessage?: ChatMessageEntry;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void; scrollToBottom?: () => void;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void; onUserAnimationConsumed: (messageId: string) => void;
@@ -783,8 +756,6 @@ const UngroupedMessageRow = React.memo(({
message, message,
previousMessage, previousMessage,
nextMessage, nextMessage,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom, scrollToBottom,
shouldAnimateUserMessage, shouldAnimateUserMessage,
onUserAnimationConsumed, onUserAnimationConsumed,
@@ -799,8 +770,6 @@ const UngroupedMessageRow = React.memo(({
nextMessage={nextMessage} nextMessage={nextMessage}
animateUserOnMount={shouldAnimateUserMessage(message)} animateUserOnMount={shouldAnimateUserMessage(message)}
onUserAnimationConsumed={onUserAnimationConsumed} onUserAnimationConsumed={onUserAnimationConsumed}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
isInActiveTurn={Boolean(activeStreamingMessageId) && message.info.id === activeStreamingMessageId} isInActiveTurn={Boolean(activeStreamingMessageId) && message.info.id === activeStreamingMessageId}
activeStreamingPhase={message.info.id === activeStreamingMessageId ? activeStreamingPhase : null} activeStreamingPhase={message.info.id === activeStreamingMessageId ? activeStreamingPhase : null}
@@ -813,8 +782,6 @@ UngroupedMessageRow.displayName = 'UngroupedMessageRow';
interface MessageListEntryProps { interface MessageListEntryProps {
entry: RenderEntry; entry: RenderEntry;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void; scrollToBottom?: () => void;
stickyUserHeader?: boolean; stickyUserHeader?: boolean;
sessionIsWorking: boolean; sessionIsWorking: boolean;
@@ -843,8 +810,6 @@ const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | unde
const MessageListEntry = React.memo(({ const MessageListEntry = React.memo(({
entry, entry,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom, scrollToBottom,
stickyUserHeader, stickyUserHeader,
sessionIsWorking, sessionIsWorking,
@@ -865,8 +830,6 @@ const MessageListEntry = React.memo(({
message={entry.message} message={entry.message}
previousMessage={entry.previousMessage} previousMessage={entry.previousMessage}
nextMessage={entry.nextMessage} nextMessage={entry.nextMessage}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
shouldAnimateUserMessage={shouldAnimateUserMessage} shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed} onUserAnimationConsumed={onUserAnimationConsumed}
@@ -892,8 +855,6 @@ const MessageListEntry = React.memo(({
activeStreamingMessageId={activeStreamingMessageId} activeStreamingMessageId={activeStreamingMessageId}
activeStreamingPhase={activeStreamingPhase} activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection} reviewTransferDirection={reviewTransferDirection}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
stickyUserHeader={stickyUserHeader} stickyUserHeader={stickyUserHeader}
/> />
@@ -906,8 +867,6 @@ MessageListEntry.displayName = 'MessageListEntry';
// `renderItem` so the render callback keeps a stable identity — a changing // `renderItem` so the render callback keeps a stable identity — a changing
// `renderItem` makes the list re-render every mounted row on every commit. // `renderItem` makes the list re-render every mounted row on every commit.
type TimelineRowContextValue = { type TimelineRowContextValue = {
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void; scrollToBottom?: () => void;
stickyUserHeader: boolean; stickyUserHeader: boolean;
defaultActivityExpanded: boolean; defaultActivityExpanded: boolean;
@@ -938,8 +897,6 @@ const TimelineRow = React.memo(({ entry }: { entry: RenderEntry }) => {
<StreamingTailContent <StreamingTailContent
entry={entry} entry={entry}
directory={context.directory} directory={context.directory}
onMessageContentChange={context.onMessageContentChange}
getAnimationHandlers={context.getAnimationHandlers}
scrollToBottom={context.scrollToBottom} scrollToBottom={context.scrollToBottom}
stickyUserHeader={context.stickyUserHeader} stickyUserHeader={context.stickyUserHeader}
sessionIsWorking={context.sessionIsWorking} sessionIsWorking={context.sessionIsWorking}
@@ -960,8 +917,6 @@ const TimelineRow = React.memo(({ entry }: { entry: RenderEntry }) => {
return ( return (
<MessageListEntry <MessageListEntry
entry={entry} entry={entry}
onMessageContentChange={context.onMessageContentChange}
getAnimationHandlers={context.getAnimationHandlers}
scrollToBottom={context.scrollToBottom} scrollToBottom={context.scrollToBottom}
stickyUserHeader={context.stickyUserHeader} stickyUserHeader={context.stickyUserHeader}
sessionIsWorking={false} sessionIsWorking={false}
@@ -1092,8 +1047,6 @@ TimelineList.displayName = 'TimelineList';
const StreamingTailContent: React.FC<{ const StreamingTailContent: React.FC<{
entry: RenderEntry; entry: RenderEntry;
directory?: string; directory?: string;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void; scrollToBottom?: () => void;
stickyUserHeader: boolean; stickyUserHeader: boolean;
sessionIsWorking: boolean; sessionIsWorking: boolean;
@@ -1110,8 +1063,6 @@ const StreamingTailContent: React.FC<{
}> = ({ }> = ({
entry, entry,
directory, directory,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom, scrollToBottom,
stickyUserHeader, stickyUserHeader,
sessionIsWorking, sessionIsWorking,
@@ -1146,8 +1097,6 @@ const StreamingTailContent: React.FC<{
return ( return (
<MessageListEntry <MessageListEntry
entry={liveEntry} entry={liveEntry}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
stickyUserHeader={stickyUserHeader} stickyUserHeader={stickyUserHeader}
sessionIsWorking={sessionIsWorking} sessionIsWorking={sessionIsWorking}
@@ -1173,8 +1122,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
activeStreamingMessageId = null, activeStreamingMessageId = null,
activeStreamingPhase = null, activeStreamingPhase = null,
retryOverlay = null, retryOverlay = null,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom, scrollToBottom,
directory, directory,
registerList, registerList,
@@ -1204,7 +1151,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
previousOrder: string[]; previousOrder: string[];
animatedIds: Set<string>; animatedIds: Set<string>;
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() }); }>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
const stableScrollToBottom = useStableEvent(() => { const stableScrollToBottom = useStableEvent(() => {
scrollToBottom?.(); scrollToBottom?.();
}); });
@@ -1421,10 +1367,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries; return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
}, [historyEntries, trailingStreamingEntry]); }, [historyEntries, trailingStreamingEntry]);
const stableHistoryContentChange = useStableEvent((reason?: ContentChangeReason) => {
onMessageContentChange(reason);
});
// Stable identities: these reach the list, where a changing callback would // Stable identities: these reach the list, where a changing callback would
// re-render every mounted row. // re-render every mounted row.
const stableIsAtEndChange = useStableEvent((isAtEnd: boolean) => { const stableIsAtEndChange = useStableEvent((isAtEnd: boolean) => {
@@ -1763,8 +1705,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
}, [allEntries, anchorMessageId, onAnchorReady, onAnchorSizeChanged]); }, [allEntries, anchorMessageId, onAnchorReady, onAnchorSizeChanged]);
const rowContext = React.useMemo(() => ({ const rowContext = React.useMemo(() => ({
onMessageContentChange: stableHistoryContentChange,
getAnimationHandlers: stableGetAnimationHandlers,
scrollToBottom: stableScrollToBottom, scrollToBottom: stableScrollToBottom,
stickyUserHeader, stickyUserHeader,
defaultActivityExpanded, defaultActivityExpanded,
@@ -1791,8 +1731,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
sessionIsWorking, sessionIsWorking,
shouldAnimateUserMessage, shouldAnimateUserMessage,
showTurnChangedFiles, showTurnChangedFiles,
stableGetAnimationHandlers,
stableHistoryContentChange,
stableScrollToBottom, stableScrollToBottom,
stickyUserHeader, stickyUserHeader,
toggleTurnGroup, toggleTurnGroup,
@@ -26,9 +26,6 @@ import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const }; 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. * The `/btw` peek panel.
* *
@@ -446,7 +443,6 @@ const BtwMessages: React.FC<{
message={record} message={record}
previousMessage={data.messageRecords[index - 1]} previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]} nextMessage={data.messageRecords[index + 1]}
onContentChange={NOOP_CONTENT_CHANGE}
isInActiveTurn={index === data.messageRecords.length - 1} isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={ activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null 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 { TurnActivityRecord } from '../lib/turns/types';
import type { ToolPopupContent } from '../message/types'; import type { ToolPopupContent } from '../message/types';
import type { StreamPhase } from '../message/types'; import type { StreamPhase } from '../message/types';
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
interface DiffStats { interface DiffStats {
additions: number; additions: number;
@@ -21,7 +20,6 @@ interface TurnActivityProps {
expandedTools: Set<string>; expandedTools: Set<string>;
onToggleTool: (toolId: string) => void; onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void; onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase; streamPhase: StreamPhase;
showHeader: boolean; showHeader: boolean;
animateRows?: boolean; animateRows?: boolean;
@@ -19,7 +19,6 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog'; import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer'; import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -419,13 +418,10 @@ interface MessageBodyProps {
onShowPopup: (content: ToolPopupContent) => void; onShowPopup: (content: ToolPopupContent) => void;
streamPhase: StreamPhase; streamPhase: StreamPhase;
allowAnimation: boolean; allowAnimation: boolean;
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
shouldShowHeader?: boolean; shouldShowHeader?: boolean;
hasTextContent?: boolean; hasTextContent?: boolean;
onCopyMessage?: () => void | boolean | Promise<void | boolean>; onCopyMessage?: () => void | boolean | Promise<void | boolean>;
copiedMessage?: boolean; copiedMessage?: boolean;
onAuxiliaryContentComplete?: () => void;
showReasoningTraces?: boolean; showReasoningTraces?: boolean;
agentMention?: AgentMentionInfo; agentMention?: AgentMentionInfo;
turnGroupingContext?: TurnGroupingContext; turnGroupingContext?: TurnGroupingContext;
@@ -1112,10 +1108,8 @@ const AssistantMessageBody = React.memo(({
onShowPopup, onShowPopup,
streamPhase: _streamPhase, streamPhase: _streamPhase,
allowAnimation: _allowAnimation, allowAnimation: _allowAnimation,
onContentChange,
hasTextContent = false, hasTextContent = false,
onCopyMessage, onCopyMessage,
onAuxiliaryContentComplete,
showReasoningTraces = false, showReasoningTraces = false,
turnGroupingContext, turnGroupingContext,
errorMessage, errorMessage,
@@ -1423,50 +1417,6 @@ const AssistantMessageBody = React.memo(({
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized)); || (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning; 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 hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
const handleForkClick = React.useCallback( const handleForkClick = React.useCallback(
@@ -1821,7 +1771,6 @@ const AssistantMessageBody = React.memo(({
expandedTools={expandedTools} expandedTools={expandedTools}
onToggleTool={onToggleTool} onToggleTool={onToggleTool}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange}
streamPhase={effectiveStreamPhase} streamPhase={effectiveStreamPhase}
showHeader={true} showHeader={true}
animateRows={animateActivityRows} animateRows={animateActivityRows}
@@ -1898,7 +1847,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId} messageId={messageId}
streamPhase={effectiveStreamPhase} streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
/> />
</div> </div>
@@ -1933,7 +1881,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId} messageId={messageId}
streamPhase={effectiveStreamPhase} streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
/> />
); );
@@ -1945,7 +1892,6 @@ const AssistantMessageBody = React.memo(({
part={part} part={part}
messageId={messageId} messageId={messageId}
streamPhase={effectiveStreamPhase} streamPhase={effectiveStreamPhase}
onContentChange={onContentChange}
/> />
); );
} }
@@ -1989,7 +1935,6 @@ const AssistantMessageBody = React.memo(({
onToggle={onToggleTool} onToggle={onToggleTool}
isMobile={isMobile} isMobile={isMobile}
alwaysShowActions={alwaysShowMessageActions} alwaysShowActions={alwaysShowMessageActions}
onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
animateTailText={animatedToolIdsLookup.has(toolPart.id)} animateTailText={animatedToolIdsLookup.has(toolPart.id)}
/> />
@@ -2061,7 +2006,6 @@ const AssistantMessageBody = React.memo(({
messageActionButtons, messageActionButtons,
renderJustificationActions, renderJustificationActions,
sessionId, sessionId,
onContentChange,
onShowPopup, onShowPopup,
onToggleTool, onToggleTool,
shouldRenderActivityGroup, shouldRenderActivityGroup,
@@ -2,7 +2,6 @@ import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import { MarkdownRenderer } from '../../MarkdownRenderer'; import { MarkdownRenderer } from '../../MarkdownRenderer';
import type { StreamPhase, ToolPopupContent } from '../types'; import type { StreamPhase, ToolPopupContent } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility'; import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug'; import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
@@ -17,7 +16,6 @@ interface AssistantTextPartProps {
messageId: string; messageId: string;
streamPhase: StreamPhase; streamPhase: StreamPhase;
chatRenderMode?: 'sorted' | 'live'; chatRenderMode?: 'sorted' | 'live';
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
} }
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { ReasoningTimelineBlock } from './ReasoningPart'; import { ReasoningTimelineBlock } from './ReasoningPart';
@@ -22,14 +21,12 @@ const cleanJustificationText = (text: string): string => {
interface JustificationBlockProps { interface JustificationBlockProps {
part: Part; part: Part;
messageId: string; messageId: string;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode; actions?: React.ReactNode;
} }
const JustificationBlock: React.FC<JustificationBlockProps> = ({ const JustificationBlock: React.FC<JustificationBlockProps> = ({
part, part,
messageId, messageId,
onContentChange,
actions, actions,
}) => { }) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode); const chatRenderMode = useUIStore((state) => state.chatRenderMode);
@@ -47,7 +44,6 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
<ReasoningTimelineBlock <ReasoningTimelineBlock
text={textContent} text={textContent}
variant="justification" variant="justification"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-justification`} blockId={part.id || `${messageId}-justification`}
time={time} time={time}
showDuration={chatRenderMode !== 'sorted'} showDuration={chatRenderMode !== 'sorted'}
@@ -4,7 +4,6 @@ import { cn } from '@/lib/utils';
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types'; import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase } from '../types'; import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
import type { ToolPopupContent } from '../types'; import type { ToolPopupContent } from '../types';
import ToolPart from './ToolPart'; import ToolPart from './ToolPart';
import { MinDurationShineText } from './MinDurationShineText'; import { MinDurationShineText } from './MinDurationShineText';
@@ -40,7 +39,6 @@ interface ProgressiveGroupProps {
expandedTools: Set<string>; expandedTools: Set<string>;
onToggleTool: (toolId: string) => void; onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void; onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase; streamPhase: StreamPhase;
showHeader: boolean; showHeader: boolean;
animateRows?: boolean; animateRows?: boolean;
@@ -376,7 +374,6 @@ interface ExpandableToolRowProps {
isMobile: boolean; isMobile: boolean;
onToggleTool: (toolId: string) => void; onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void; onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
animateTailText: boolean; animateTailText: boolean;
} }
@@ -386,7 +383,6 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
isMobile, isMobile,
onToggleTool, onToggleTool,
onShowPopup, onShowPopup,
onContentChange,
animateTailText, animateTailText,
}) => { }) => {
const handleToggle = React.useCallback(() => { const handleToggle = React.useCallback(() => {
@@ -399,7 +395,6 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
isExpanded={isExpanded} isExpanded={isExpanded}
onToggle={handleToggle} onToggle={handleToggle}
isMobile={isMobile} isMobile={isMobile}
onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
animateTailText={animateTailText} animateTailText={animateTailText}
/> />
@@ -423,7 +418,6 @@ const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
&& prev.isMobile === next.isMobile && prev.isMobile === next.isMobile
&& prev.onToggleTool === next.onToggleTool && prev.onToggleTool === next.onToggleTool
&& prev.onShowPopup === next.onShowPopup && prev.onShowPopup === next.onShowPopup
&& prev.onContentChange === next.onContentChange
&& prev.animateTailText === next.animateTailText && prev.animateTailText === next.animateTailText
&& prev.activity.id === next.activity.id && prev.activity.id === next.activity.id
&& prev.activity.kind === next.activity.kind && 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. * Inline reasoning text block — rendered as dimmed italic markdown.
*/ */
const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhase }: { const InlineReasoningBlock = React.memo(({ activity, streamPhase }: {
activity: TurnActivityPart; activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase; streamPhase: StreamPhase;
}) => { }) => {
return ( return (
@@ -799,7 +792,6 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
part={activity.part} part={activity.part}
messageId={activity.messageId} messageId={activity.messageId}
streamPhase={streamPhase} 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. * 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; activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode; actions?: React.ReactNode;
}) => { }) => {
return ( return (
<JustificationBlock <JustificationBlock
part={activity.part} part={activity.part}
messageId={activity.messageId} messageId={activity.messageId}
onContentChange={onContentChange}
actions={actions} actions={actions}
/> />
); );
@@ -831,7 +821,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
expandedTools, expandedTools,
onToggleTool, onToggleTool,
onShowPopup, onShowPopup,
onContentChange,
streamPhase, streamPhase,
showHeader, showHeader,
animateRows = true, animateRows = true,
@@ -892,7 +881,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<InlineReasoningBlock <InlineReasoningBlock
activity={row.activity} activity={row.activity}
streamPhase={streamPhase} streamPhase={streamPhase}
onContentChange={onContentChange}
/> />
</> </>
); );
@@ -903,7 +891,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<> <>
<InlineJustificationBlock <InlineJustificationBlock
activity={row.activity} activity={row.activity}
onContentChange={onContentChange}
actions={renderJustificationActions?.(row.activity)} actions={renderJustificationActions?.(row.activity)}
/> />
</> </>
@@ -918,7 +905,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile={isMobile} isMobile={isMobile}
onToggleTool={onToggleTool} onToggleTool={onToggleTool}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
/> />
); );
@@ -942,7 +928,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile={isMobile} isMobile={isMobile}
onToggleTool={onToggleTool} onToggleTool={onToggleTool}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
/> />
); );
@@ -2,7 +2,6 @@ import React from 'react';
import { animate, type AnimationPlaybackControls } from 'motion'; import { animate, type AnimationPlaybackControls } from 'motion';
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { BusyDots } from './BusyDots'; import { BusyDots } from './BusyDots';
@@ -81,7 +80,6 @@ const getReasoningSummary = (text: string): string => {
type ReasoningTimelineBlockProps = { type ReasoningTimelineBlockProps = {
text: string; text: string;
variant: ReasoningVariant; variant: ReasoningVariant;
onContentChange?: (reason?: ContentChangeReason) => void;
blockId: string; blockId: string;
time?: { start?: number; end?: number }; time?: { start?: number; end?: number };
showDuration?: boolean; showDuration?: boolean;
@@ -99,7 +97,6 @@ type ExpansionState = {
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
text, text,
variant, variant,
onContentChange,
blockId, blockId,
time, time,
isStreaming = false, isStreaming = false,
@@ -123,11 +120,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const contentRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null);
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null); const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
const contentMountedRef = React.useRef(false); 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 summary = React.useMemo(() => getReasoningSummary(text), [text]);
const toggleAriaLabel = isExpanded const toggleAriaLabel = isExpanded
@@ -137,8 +129,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const handleToggle = React.useCallback(() => { const handleToggle = React.useCallback(() => {
setShouldRenderExpandedContent(true); setShouldRenderExpandedContent(true);
setExpansion({ expanded: !isExpanded, source: 'user' }); setExpansion({ expanded: !isExpanded, source: 'user' });
onContentChange?.('structural'); }, [isExpanded]);
}, [isExpanded, onContentChange]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
@@ -159,13 +150,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
}); });
}, [canAutoExpand]); }, [canAutoExpand]);
React.useEffect(() => {
if (text.trim().length === 0) {
return;
}
onContentChange?.('structural');
}, [onContentChange, text]);
React.useEffect(() => { React.useEffect(() => {
if (isExpanded || isStreaming) { if (isExpanded || isStreaming) {
setShouldRenderExpandedContent(true); setShouldRenderExpandedContent(true);
@@ -239,11 +223,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
element.style.height = '0px'; element.style.height = '0px';
} else { } else {
element.style.height = `${element.scrollHeight}px`; 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( const animation = animate(
@@ -436,14 +415,12 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
type ReasoningPartProps = { type ReasoningPartProps = {
part: Part; part: Part;
onContentChange?: (reason?: ContentChangeReason) => void;
messageId: string; messageId: string;
streamPhase?: StreamPhase; streamPhase?: StreamPhase;
}; };
const ReasoningPart = React.memo(({ const ReasoningPart = React.memo(({
part, part,
onContentChange,
messageId, messageId,
streamPhase, streamPhase,
}: ReasoningPartProps) => { }: ReasoningPartProps) => {
@@ -470,7 +447,6 @@ const ReasoningPart = React.memo(({
<ReasoningTimelineBlock <ReasoningTimelineBlock
text={throttledText} text={throttledText}
variant="thinking" variant="thinking"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning`} blockId={part.id || `${messageId}-reasoning`}
time={time} time={time}
isStreaming={isStreaming} isStreaming={isStreaming}
@@ -20,7 +20,6 @@ import { toast } from '@/components/ui';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
import type { ToolPopupContent } from '../types'; import type { ToolPopupContent } from '../types';
import { PlainDiffFallback } from './PlainDiffFallback'; import { PlainDiffFallback } from './PlainDiffFallback';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -82,7 +81,6 @@ interface ToolPartProps {
onToggle: (toolId: string) => void; onToggle: (toolId: string) => void;
isMobile: boolean; isMobile: boolean;
alwaysShowActions?: boolean; alwaysShowActions?: boolean;
onContentChange?: (reason?: ContentChangeReason) => void;
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
animateTailText?: boolean; animateTailText?: boolean;
} }
@@ -1684,7 +1682,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
isExpanded, isExpanded,
onToggle, onToggle,
isMobile, isMobile,
onContentChange,
onShowPopup, onShowPopup,
animateTailText = true, animateTailText = true,
}) => { }) => {
@@ -1754,10 +1751,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}); });
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]); }, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange;
const expandedContentRef = React.useRef<HTMLDivElement>(null); const expandedContentRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
@@ -1772,11 +1765,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
element.style.height = isExpanded ? 'auto' : '0px'; element.style.height = isExpanded ? 'auto' : '0px';
element.style.overflow = isExpanded ? 'visible' : 'hidden'; element.style.overflow = isExpanded ? 'visible' : 'hidden';
}, [isExpanded, isTaskTool]);
if (shouldNotifyStructuralChange) {
onContentChangeRef.current?.('structural');
}
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
const partMetadata = (part as unknown as { metadata?: unknown }).metadata; const partMetadata = (part as unknown as { metadata?: unknown }).metadata;
const time = stateWithData.time; const time = stateWithData.time;
@@ -1934,26 +1923,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
} }
return metadataTaskSummaryEntries; return metadataTaskSummaryEntries;
}, [childSessionTaskSummaryEntries, 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(() => { const diffStats = React.useMemo(() => {
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch') return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
? parseDiffStats(metadata) ? parseDiffStats(metadata)
@@ -2351,7 +2320,6 @@ export default React.memo(ToolPart, (prev, next) => {
&& prev.isExpanded === next.isExpanded && prev.isExpanded === next.isExpanded
&& prev.isMobile === next.isMobile && prev.isMobile === next.isMobile
&& prev.alwaysShowActions === next.alwaysShowActions && prev.alwaysShowActions === next.alwaysShowActions
&& prev.onContentChange === next.onContentChange
&& prev.onShowPopup === next.onShowPopup && prev.onShowPopup === next.onShowPopup
&& prev.animateTailText === next.animateTailText; && prev.animateTailText === next.animateTailText;
}); });
@@ -37,22 +37,6 @@ import {
// guard/settle/entry-stick timers here. // 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 // 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 // hook stays testable without a renderer and does not hard-depend on the list
// implementation. // implementation.
@@ -105,8 +89,6 @@ export interface UseChatTimelineScrollResult {
isFollowingProgrammatically: boolean; isFollowingProgrammatically: boolean;
goToBottom: (mode?: 'instant' | 'smooth') => void; goToBottom: (mode?: 'instant' | 'smooth') => void;
scrollToBottomOnSend: () => void; scrollToBottomOnSend: () => void;
notifyContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
saveSnapshotNow: () => void; saveSnapshotNow: () => void;
restoreSnapshot: () => Promise<boolean>; restoreSnapshot: () => Promise<boolean>;
} }
@@ -125,8 +107,6 @@ const ANCHOR_POSITION_ATTEMPTS = 12;
// a genuine relayout and must not be undone. // a genuine relayout and must not be undone.
const ANCHOR_RESTORE_TOLERANCE_PX = 2; const ANCHOR_RESTORE_TOLERANCE_PX = 2;
const NOOP = (): void => {};
export const useChatTimelineScroll = ({ export const useChatTimelineScroll = ({
currentSessionId, currentSessionId,
currentSessionKey, currentSessionKey,
@@ -790,18 +770,6 @@ export const useChatTimelineScroll = ({
}; };
}, [onActiveTurnChange, scrollNode]); }, [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 { return {
scrollRef, scrollRef,
scrollNode, scrollNode,
@@ -818,8 +786,6 @@ export const useChatTimelineScroll = ({
isFollowingProgrammatically, isFollowingProgrammatically,
goToBottom, goToBottom,
scrollToBottomOnSend, scrollToBottomOnSend,
notifyContentChange: NOOP,
getAnimationHandlers,
saveSnapshotNow, saveSnapshotNow,
restoreSnapshot, restoreSnapshot,
}; };