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:
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user