refactor: streamline chat scroll manager

- Removed unused activeTurnAnchorId and activeTurnSpacerHeight from MemoryDebugPanel.
- Simplified useChatScrollManager by eliminating unnecessary state and functions related to active turn anchoring.
- Updated loadMessages function signatures to accept an optional limit parameter across message and session stores.
- Enhanced scroll behavior in useScrollEngine to support dynamic bottom tracking during animations.
- Improved event stream handling to allow for resyncing messages with a specified limit.
- Cleaned up session memory state to remove active turn properties, focusing on viewport anchoring.
This commit is contained in:
Bohdan Triapitsyn
2026-01-20 03:23:39 +02:00
parent 8fa16baf3b
commit 6544b12e78
15 changed files with 318 additions and 937 deletions
@@ -25,8 +25,6 @@ export const ChatContainer: React.FC = () => {
loadMessages, loadMessages,
loadMoreMessages, loadMoreMessages,
updateViewportAnchor, updateViewportAnchor,
updateActiveTurnAnchor,
getActiveTurnAnchor,
sessionMemoryState, sessionMemoryState,
openNewSessionDraft, openNewSessionDraft,
isSyncing, isSyncing,
@@ -78,23 +76,19 @@ export const ChatContainer: React.FC = () => {
getAnimationHandlers, getAnimationHandlers,
showScrollButton, showScrollButton,
scrollToBottom, scrollToBottom,
spacerHeight, scrollToPosition,
pendingAnchorId, isPinned,
hasActiveAnchor,
} = useChatScrollManager({ } = useChatScrollManager({
currentSessionId, currentSessionId,
sessionMessages, sessionMessages,
streamingMessageId, streamingMessageId,
sessionMemoryState, sessionMemoryState,
updateViewportAnchor, updateViewportAnchor,
updateActiveTurnAnchor,
getActiveTurnAnchor,
isSyncing, isSyncing,
isMobile, isMobile,
messageStreamStates, messageStreamStates,
sessionPermissions: sessionBlockingCards, sessionPermissions: sessionBlockingCards,
trimToViewportWindow, trimToViewportWindow,
sessionActivityPhase,
}); });
const memoryState = React.useMemo(() => { const memoryState = React.useMemo(() => {
@@ -123,12 +117,12 @@ export const ChatContainer: React.FC = () => {
await loadMoreMessages(currentSessionId, 'up'); await loadMoreMessages(currentSessionId, 'up');
if (container && prevHeight !== null && prevTop !== null) { if (container && prevHeight !== null && prevTop !== null) {
const heightDiff = container.scrollHeight - prevHeight; const heightDiff = container.scrollHeight - prevHeight;
container.scrollTop = prevTop + heightDiff; scrollToPosition(prevTop + heightDiff, { instant: true });
} }
} finally { } finally {
setIsLoadingOlder(false); setIsLoadingOlder(false);
} }
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef]); }, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]);
// Scroll to a specific message by ID (for timeline dialog) // Scroll to a specific message by ID (for timeline dialog)
const scrollToMessage = React.useCallback((messageId: string) => { const scrollToMessage = React.useCallback((messageId: string) => {
@@ -169,7 +163,8 @@ export const ChatContainer: React.FC = () => {
} finally { } finally {
const currentPhase = sessionActivityPhase?.get(currentSessionId) ?? 'idle'; const currentPhase = sessionActivityPhase?.get(currentSessionId) ?? 'idle';
const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown'; const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown';
const shouldSkipScroll = isActivePhase && hasActiveAnchor; // When pinned and active, scroll is already maintained automatically
const shouldSkipScroll = isActivePhase && isPinned;
if (!shouldSkipScroll) { if (!shouldSkipScroll) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -184,7 +179,7 @@ export const ChatContainer: React.FC = () => {
}; };
void load(); void load();
}, [currentSessionId, hasActiveAnchor, loadMessages, messages, scrollToBottom, sessionActivityPhase]); }, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionActivityPhase]);
if (!currentSessionId && !draftOpen) { if (!currentSessionId && !draftOpen) {
return ( return (
@@ -277,7 +272,6 @@ export const ChatContainer: React.FC = () => {
}} }}
data-scroll-shadow="true" data-scroll-shadow="true"
data-scrollbar="chat" data-scrollbar="chat"
hideBottomShadow={!!pendingAnchorId}
> >
<div className="relative z-0 min-h-full"> <div className="relative z-0 min-h-full">
<MessageList <MessageList
@@ -290,16 +284,7 @@ export const ChatContainer: React.FC = () => {
isLoadingOlder={isLoadingOlder} isLoadingOlder={isLoadingOlder}
onLoadOlder={handleLoadOlder} onLoadOlder={handleLoadOlder}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
pendingAnchorId={pendingAnchorId}
/> />
{}
{spacerHeight > 0 && hasActiveAnchor && (
<div
data-role="active-turn-spacer"
style={{ height: spacerHeight }}
aria-hidden="true"
/>
)}
</div> </div>
</ScrollShadow> </ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} /> <OverlayScrollbar containerRef={scrollRef} />
@@ -360,6 +360,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return; if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return;
// Re-pin and scroll to bottom when sending
scrollToBottom?.({ instant: true, force: true }); scrollToBottom?.({ instant: true, force: true });
if (!currentProviderId || !currentModelId) { if (!currentProviderId || !currentModelId) {
@@ -1356,12 +1357,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className={cn( className={cn(
"pt-0 pb-2 md:pb-4", "relative pt-0 pb-2 md:pb-4",
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area" isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
)} )}
data-keyboard-avoid="true" data-keyboard-avoid="true"
style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined} style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}
> >
{/* Absolute positioned above input - no layout shift */}
<div className="absolute bottom-full left-0 right-0">
<StatusRow <StatusRow
isWorking={working.isWorking} isWorking={working.isWorking}
statusText={workingStatusText} statusText={workingStatusText}
@@ -1375,6 +1378,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
onAbort={handleAbort} onAbort={handleAbort}
showAbortStatus={showAbortStatus} showAbortStatus={showAbortStatus}
/> />
</div>
<div <div
ref={dropZoneRef} ref={dropZoneRef}
className={cn( className={cn(
@@ -63,8 +63,7 @@ interface ChatMessageProps {
}; };
onContentChange?: (reason?: ContentChangeReason) => void; onContentChange?: (reason?: ContentChangeReason) => void;
animationHandlers?: AnimationHandlers; animationHandlers?: AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
isPendingAnchor?: boolean;
turnGroupingContext?: TurnGroupingContext; turnGroupingContext?: TurnGroupingContext;
} }
@@ -74,7 +73,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
nextMessage, nextMessage,
onContentChange, onContentChange,
animationHandlers, animationHandlers,
isPendingAnchor = false,
turnGroupingContext, turnGroupingContext,
}) => { }) => {
const { isMobile, hasTouchInput } = useDeviceInfo(); const { isMobile, hasTouchInput } = useDeviceInfo();
@@ -834,7 +832,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
)} )}
data-message-id={message.info.id} data-message-id={message.info.id}
ref={messageContainerRef} ref={messageContainerRef}
style={isPendingAnchor ? { visibility: 'hidden' } : undefined}
> >
<div className="chat-column"> <div className="chat-column">
{isUser ? ( {isUser ? (
@@ -19,8 +19,7 @@ interface MessageListProps {
hasMoreAbove: boolean; hasMoreAbove: boolean;
isLoadingOlder: boolean; isLoadingOlder: boolean;
onLoadOlder: () => void; onLoadOlder: () => void;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
pendingAnchorId?: string | null;
} }
const MessageList: React.FC<MessageListProps> = ({ const MessageList: React.FC<MessageListProps> = ({
@@ -33,7 +32,6 @@ const MessageList: React.FC<MessageListProps> = ({
isLoadingOlder, isLoadingOlder,
onLoadOlder, onLoadOlder,
scrollToBottom, scrollToBottom,
pendingAnchorId,
}) => { }) => {
React.useEffect(() => { React.useEffect(() => {
if (permissions.length === 0 && questions.length === 0) { if (permissions.length === 0 && questions.length === 0) {
@@ -101,7 +99,6 @@ const MessageList: React.FC<MessageListProps> = ({
onContentChange={onMessageContentChange} onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)} animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
isPendingAnchor={pendingAnchorId === message.info.id}
turnGroupingContext={getContextForMessage(message.info.id)} turnGroupingContext={getContextForMessage(message.info.id)}
/> />
))} ))}
@@ -118,6 +115,9 @@ const MessageList: React.FC<MessageListProps> = ({
))} ))}
</div> </div>
)} )}
{/* Bottom spacer - always 10% of viewport height */}
<div className="flex-shrink-0" style={{ height: '10vh' }} aria-hidden="true" />
</div> </div>
); );
}; };
@@ -56,15 +56,12 @@ export interface TurnGroupingContext {
isWorking: boolean; isWorking: boolean;
isGroupExpanded: boolean; isGroupExpanded: boolean;
previewedPartIds: Set<string>;
toggleGroup: () => void; toggleGroup: () => void;
markPartsPreviewed: (partIds: string[]) => void;
} }
interface TurnUiState { interface TurnUiState {
isExpanded: boolean; isExpanded: boolean;
previewedPartIds: Set<string>;
} }
interface TurnActivityInfo { interface TurnActivityInfo {
@@ -424,7 +421,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
(turnId: string): TurnUiState => { (turnId: string): TurnUiState => {
const existing = turnUiStates.get(turnId); const existing = turnUiStates.get(turnId);
if (existing) return existing; if (existing) return existing;
return { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() }; return { isExpanded: defaultActivityExpanded };
}, },
[turnUiStates, defaultActivityExpanded] [turnUiStates, defaultActivityExpanded]
); );
@@ -432,25 +429,8 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
const toggleGroup = React.useCallback((turnId: string) => { const toggleGroup = React.useCallback((turnId: string) => {
setTurnUiStates((prev) => { setTurnUiStates((prev) => {
const next = new Map(prev); const next = new Map(prev);
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() }; const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded };
next.set(turnId, { ...current, isExpanded: !current.isExpanded }); next.set(turnId, { isExpanded: !current.isExpanded });
return next;
});
}, [defaultActivityExpanded]);
const markPartsPreviewedInternal = React.useCallback((turnId: string, partIds: string[]) => {
if (partIds.length === 0) return;
setTurnUiStates((prev) => {
const next = new Map(prev);
const state = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
const newPreviewed = new Set(state.previewedPartIds);
partIds.forEach((id) => {
if (id && id.trim().length > 0) {
newPreviewed.add(id);
}
});
next.set(turnId, { ...state, previewedPartIds: newPreviewed });
return next; return next;
}); });
}, [defaultActivityExpanded]); }, [defaultActivityExpanded]);
@@ -506,12 +486,10 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
userMessageCreatedAt, userMessageCreatedAt,
isWorking: isTurnWorking, isWorking: isTurnWorking,
isGroupExpanded: uiState.isExpanded, isGroupExpanded: uiState.isExpanded,
previewedPartIds: uiState.previewedPartIds,
toggleGroup: () => toggleGroup(turn.turnId), toggleGroup: () => toggleGroup(turn.turnId),
markPartsPreviewed: (partIds: string[]) => markPartsPreviewedInternal(turn.turnId, partIds),
} satisfies TurnGroupingContext; } satisfies TurnGroupingContext;
}, },
[getOrCreateTurnState, lastTurnId, markPartsPreviewedInternal, messageToTurn, sessionIsWorking, toggleGroup, turnActivityInfo] [getOrCreateTurnState, lastTurnId, messageToTurn, sessionIsWorking, toggleGroup, turnActivityInfo]
); );
@@ -4,15 +4,16 @@ import { cn } from '@/lib/utils';
interface FadeInOnRevealProps { interface FadeInOnRevealProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
skipAnimation?: boolean;
} }
const FADE_ANIMATION_ENABLED = true; const FADE_ANIMATION_ENABLED = true;
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className }) => { export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className, skipAnimation }) => {
const [visible, setVisible] = React.useState(false); const [visible, setVisible] = React.useState(skipAnimation ?? false);
React.useEffect(() => { React.useEffect(() => {
if (!FADE_ANIMATION_ENABLED) { if (!FADE_ANIMATION_ENABLED || skipAnimation) {
return; return;
} }
@@ -35,9 +36,9 @@ export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, classN
window.cancelAnimationFrame(frame); window.cancelAnimationFrame(frame);
} }
}; };
}, []); }, [skipAnimation]);
if (!FADE_ANIMATION_ENABLED) { if (!FADE_ANIMATION_ENABLED || skipAnimation) {
return <>{children}</>; return <>{children}</>;
} }
@@ -1,12 +1,9 @@
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 AssistantTextPart from './parts/AssistantTextPart';
import UserTextPart from './parts/UserTextPart'; import UserTextPart from './parts/UserTextPart';
import ReasoningPart from './parts/ReasoningPart';
import ToolPart from './parts/ToolPart'; import ToolPart from './parts/ToolPart';
import ProgressiveGroup from './parts/ProgressiveGroup'; import ProgressiveGroup from './parts/ProgressiveGroup';
import MigratingPart from './parts/MigratingPart';
import { MessageFilesDisplay } from '../FileAttachment'; import { MessageFilesDisplay } from '../FileAttachment';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types'; import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
@@ -37,77 +34,6 @@ const formatTurnDuration = (durationMs: number): string => {
return `${minutes}m ${seconds}s`; return `${minutes}m ${seconds}s`;
}; };
const useMigrationTimer = (
turnGroupingContext: TurnGroupingContext | undefined,
previewablePartIds: Set<string>
): { isAnimating: boolean } => {
const timerRef = React.useRef<number | null>(null);
const animationTimerRef = React.useRef<number | null>(null);
const [isAnimating, setIsAnimating] = React.useState(false);
const contextRef = React.useRef(turnGroupingContext);
contextRef.current = turnGroupingContext;
const partIdsRef = React.useRef(previewablePartIds);
partIdsRef.current = previewablePartIds;
const timerStartedRef = React.useRef(false);
const hasPreviewableParts = previewablePartIds.size > 0;
React.useEffect(() => {
if (!turnGroupingContext) return;
if (!turnGroupingContext.isWorking) return;
if (!hasPreviewableParts) return;
if (timerStartedRef.current) return;
timerStartedRef.current = true;
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
setIsAnimating(true);
animationTimerRef.current = window.setTimeout(() => {
animationTimerRef.current = null;
setIsAnimating(false);
const context = contextRef.current;
if (!context) {
return;
}
const idsToPreview = Array.from(partIdsRef.current);
if (idsToPreview.length > 0) {
context.markPartsPreviewed(idsToPreview);
}
}, 300);
}, 1000);
}, [hasPreviewableParts, turnGroupingContext]);
React.useEffect(() => {
if (!turnGroupingContext) return;
if (!turnGroupingContext.isWorking || !hasPreviewableParts) {
if (timerRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
if (animationTimerRef.current) {
window.clearTimeout(animationTimerRef.current);
animationTimerRef.current = null;
}
setIsAnimating(false);
timerStartedRef.current = false;
}
}, [hasPreviewableParts, turnGroupingContext]);
React.useEffect(() => {
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
if (animationTimerRef.current) window.clearTimeout(animationTimerRef.current);
};
}, []);
return { isAnimating };
};
const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']); const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
const isActivityStandaloneTool = (toolName: unknown): boolean => { const isActivityStandaloneTool = (toolName: unknown): boolean => {
@@ -429,15 +355,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return toolParts.every((toolPart) => isToolFinalized(toolPart)); return toolParts.every((toolPart) => isToolFinalized(toolPart));
}, [toolParts, hasPendingTools, isToolFinalized]); }, [toolParts, hasPendingTools, isToolFinalized]);
const assistantTextReady = React.useMemo(() => {
if (assistantTextParts.length === 0) {
return true;
}
return assistantTextParts.every((part) => {
const time = (part as Record<string, unknown>).time as Record<string, unknown> | undefined;
return typeof time?.end === 'number';
});
}, [assistantTextParts]);
const reasoningParts = React.useMemo(() => { const reasoningParts = React.useMemo(() => {
return visibleParts.filter((part) => part.type === 'reasoning'); return visibleParts.filter((part) => part.type === 'reasoning');
@@ -461,19 +378,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
hasTools && hasTools &&
(hasPendingTools || hasOpenStep || !allToolsFinalized); (hasPendingTools || hasOpenStep || !allToolsFinalized);
const shouldCoordinateRendering = React.useMemo(() => {
if (!hasTools) {
return assistantTextParts.length > 0 ? shouldHoldForReasoning : false;
}
if (assistantTextParts.length === 0) {
return hasOpenStep || hasPendingTools || !allToolsFinalized;
}
return true;
}, [assistantTextParts.length, hasOpenStep, hasPendingTools, hasTools, shouldHoldForReasoning, allToolsFinalized]);
const shouldHoldAssistantText = awaitingMessageCompletion
|| (shouldCoordinateRendering && (!assistantTextReady || !allToolsFinalized || hasPendingTools || hasOpenStep))
|| shouldHoldForReasoning;
const shouldHoldTools = awaitingMessageCompletion const shouldHoldTools = awaitingMessageCompletion
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized)); || (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning; const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
@@ -682,70 +587,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const shouldShowActivityGroup = Boolean(turnGroupingContext && hasEverHadMultipleVisibleActivities); const shouldShowActivityGroup = Boolean(turnGroupingContext && hasEverHadMultipleVisibleActivities);
const previewableActivityPartsForMessage = React.useMemo(() => {
if (!turnGroupingContext) return [];
if (!shouldShowActivityGroup) return [];
if (!turnGroupingContext.isWorking) {
return [];
}
const previewable: (typeof activityPartsForMessage) = [];
activityPartsForMessage.forEach((activity) => {
if (turnGroupingContext.previewedPartIds.has(activity.id)) {
return;
}
if (!showReasoningTraces && activity.kind !== 'tool') {
return;
}
const part = activity.part;
if (activity.kind === 'tool') {
const toolPart = part as ToolPartType;
if (isActivityStandaloneTool(toolPart.tool)) {
return;
}
if (shouldHoldTools) return;
if (!isToolFinalized(toolPart)) return;
} else if (activity.kind === 'reasoning') {
if (!showReasoningTraces) return;
if (shouldHoldReasoning) return;
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
if (typeof time?.end !== 'number') return;
} else if (activity.kind === 'justification') {
if (!showReasoningTraces) return;
if (shouldHoldAssistantText) return;
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
if (typeof time?.end !== 'number') return;
}
previewable.push(activity);
});
return previewable;
}, [
activityPartsForMessage,
isToolFinalized,
shouldHoldAssistantText,
shouldHoldReasoning,
shouldHoldTools,
showReasoningTraces,
shouldShowActivityGroup,
turnGroupingContext,
]);
const previewableActivityPartIds = React.useMemo(() => {
const ids = new Set<string>();
previewableActivityPartsForMessage.forEach((activity) => {
ids.add(activity.id);
});
return ids;
}, [previewableActivityPartsForMessage]);
const { isAnimating: isMessageAnimating } = useMigrationTimer(turnGroupingContext, previewableActivityPartIds);
const shouldRenderActivityGroup = Boolean( const shouldRenderActivityGroup = Boolean(
turnGroupingContext && turnGroupingContext &&
shouldShowActivityGroup && shouldShowActivityGroup &&
@@ -789,8 +630,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
onToggleTool={onToggleTool} onToggleTool={onToggleTool}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange} onContentChange={onContentChange}
isWorking={turnGroupingContext.isWorking}
previewedPartIds={turnGroupingContext.previewedPartIds}
diffStats={turnGroupingContext.diffStats} diffStats={turnGroupingContext.diffStats}
/> />
); );
@@ -885,146 +724,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
element, element,
}); });
} }
return;
}
if (!turnGroupingContext.isWorking || turnGroupingContext.isGroupExpanded) {
return;
}
if (turnGroupingContext.previewedPartIds.has(activity.id)) {
return;
}
if (!showReasoningTraces && activity.kind !== 'tool') {
return;
}
const wrapForMigration = previewableActivityPartIds.has(activity.id);
switch (activity.kind) {
case 'tool': {
const toolPart = part as ToolPartType;
if (isActivityStandaloneTool(toolPart.tool)) {
break;
}
const toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
const time = toolState?.time;
const isFinalized = isToolFinalized(toolPart);
const shouldShowTool = !shouldHoldTools && isFinalized;
if (!shouldShowTool) {
break;
}
const connection = toolConnections[toolPart.id];
const toolElement = (
<FadeInOnReveal key={`tool-${toolPart.id}`}>
<ToolPart
part={toolPart}
isExpanded={expandedTools.has(toolPart.id)}
onToggle={onToggleTool}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onContentChange={onContentChange}
hasPrevTool={connection?.hasPrev ?? false}
hasNextTool={connection?.hasNext ?? false}
/>
</FadeInOnReveal>
);
element = wrapForMigration ? (
<MigratingPart key={`migrating-tool-${toolPart.id}`} isMigrating={isMessageAnimating}>
{toolElement}
</MigratingPart>
) : toolElement;
endTime = isFinalized && typeof time?.end === 'number' ? time.end : null;
break;
}
case 'reasoning': {
if (!showReasoningTraces) {
break;
}
const reasoningTime = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
const hasEndTime = typeof reasoningTime?.end === 'number';
const shouldShowReasoning = hasEndTime && !shouldHoldReasoning;
if (!shouldShowReasoning) {
break;
}
const reasoningElement = (
<FadeInOnReveal key={`reasoning-${index}`}>
<ReasoningPart
part={part}
messageId={messageId}
onContentChange={onContentChange}
/>
</FadeInOnReveal>
);
element = wrapForMigration ? (
<MigratingPart key={`migrating-reasoning-${index}`} isMigrating={isMessageAnimating}>
{reasoningElement}
</MigratingPart>
) : reasoningElement;
endTime = hasEndTime ? reasoningTime?.end ?? null : null;
break;
}
case 'justification': {
if (!showReasoningTraces) {
break;
}
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
const hasEndTime = typeof time?.end === 'number';
const shouldShowJustification = hasEndTime && !shouldHoldAssistantText;
if (!shouldShowJustification) {
break;
}
const textElement = (
<FadeInOnReveal key={`assistant-text-${index}`}>
<AssistantTextPart
part={part}
messageId={messageId}
streamPhase="completed"
allowAnimation={false}
onContentChange={onContentChange}
renderAsReasoning
/>
</FadeInOnReveal>
);
element = wrapForMigration ? (
<MigratingPart key={`migrating-text-${index}`} isMigrating={isMessageAnimating}>
{textElement}
</MigratingPart>
) : textElement;
endTime = hasEndTime ? time?.end ?? null : null;
break;
}
default:
break;
}
if (element) {
partsWithTime.push({
part,
index,
endTime,
element,
});
} }
}); });
@@ -1050,16 +749,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
activityPartsByPart, activityPartsByPart,
activityGroupSegmentsForMessage, activityGroupSegmentsForMessage,
expandedTools, expandedTools,
isMessageAnimating,
isMobile, isMobile,
isToolFinalized, isToolFinalized,
messageId,
onContentChange, onContentChange,
onShowPopup, onShowPopup,
onToggleTool, onToggleTool,
previewableActivityPartIds,
shouldHoldAssistantText,
shouldHoldReasoning,
shouldHoldTools, shouldHoldTools,
shouldShowActivityGroup, shouldShowActivityGroup,
showReasoningTraces, showReasoningTraces,
@@ -10,6 +10,8 @@ import ReasoningPart from './ReasoningPart';
import JustificationBlock from './JustificationBlock'; import JustificationBlock from './JustificationBlock';
import { FadeInOnReveal } from '../FadeInOnReveal'; import { FadeInOnReveal } from '../FadeInOnReveal';
const MAX_VISIBLE_COLLAPSED = 6;
interface DiffStats { interface DiffStats {
additions: number; additions: number;
deletions: number; deletions: number;
@@ -26,32 +28,9 @@ interface ProgressiveGroupProps {
onToggleTool: (toolId: string) => void; onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void; onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void; onContentChange?: (reason?: ContentChangeReason) => void;
isWorking: boolean;
previewedPartIds: Set<string>;
diffStats?: DiffStats; diffStats?: DiffStats;
} }
const getGroupSummary = (parts: TurnActivityPart[]): string => {
const counts = {
tools: parts.filter((p) => p.kind === 'tool').length,
reasoning: parts.filter((p) => p.kind === 'reasoning').length,
justifications: parts.filter((p) => p.kind === 'justification').length,
};
const segments: string[] = [];
if (counts.tools > 0) {
segments.push(`${counts.tools} tool${counts.tools > 1 ? 's' : ''}`);
}
if (counts.reasoning > 0) {
segments.push(`${counts.reasoning} reasoning`);
}
if (counts.justifications > 0) {
segments.push(`${counts.justifications} justification${counts.justifications > 1 ? 's' : ''}`);
}
return segments.join(', ');
};
const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => { const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => {
return [...parts].sort((a, b) => { return [...parts].sort((a, b) => {
const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined; const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined;
@@ -93,13 +72,12 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
expandedTools, expandedTools,
onToggleTool, onToggleTool,
onContentChange, onContentChange,
isWorking,
previewedPartIds,
diffStats, diffStats,
}) => { }) => {
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded); const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
// Track if we just expanded from collapsed state
const [justExpandedFromCollapsed, setJustExpandedFromCollapsed] = React.useState(false);
// Track expansion count to force re-mount of items when group expands from collapsed
const [expansionKey, setExpansionKey] = React.useState(0); const [expansionKey, setExpansionKey] = React.useState(0);
React.useEffect(() => { React.useEffect(() => {
@@ -108,51 +86,68 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
previousExpandedRef.current = isExpanded; previousExpandedRef.current = isExpanded;
onContentChange?.('structural'); onContentChange?.('structural');
// Increment key when expanding to trigger fresh animations
if (isExpanded && wasCollapsed) { if (isExpanded && wasCollapsed) {
setExpansionKey((k) => k + 1); setExpansionKey((k) => k + 1);
setJustExpandedFromCollapsed(true);
// Reset after a short delay (after animations would have started)
const timer = setTimeout(() => setJustExpandedFromCollapsed(false), 50);
return () => clearTimeout(timer);
} else {
setJustExpandedFromCollapsed(false);
} }
}, [isExpanded, onContentChange]); }, [isExpanded, onContentChange]);
const displayParts = React.useMemo(() => { const displayParts = React.useMemo(() => {
if (!isWorking) {
return sortPartsByTime(parts); return sortPartsByTime(parts);
} }, [parts]);
// While turn is working, only show parts that have been "previewed".
// Collapsed mode previews them in-chat first, then migrates into Activity.
// Summary/Detailed modes skip in-chat preview, but still use the same migration gate.
return sortPartsByTime(
parts.filter((activity) => {
const partId = activity.part.id;
return partId && previewedPartIds.has(activity.id);
})
);
}, [parts, isWorking, previewedPartIds]);
const summary = getGroupSummary(displayParts);
const toolConnections = getToolConnections(displayParts); const toolConnections = getToolConnections(displayParts);
// For collapsed state: show last N items
const visibleCollapsedParts = React.useMemo(() => {
return displayParts.slice(-MAX_VISIBLE_COLLAPSED);
}, [displayParts]);
// Set of part IDs that were visible in collapsed state
const visibleInCollapsedIds = React.useMemo(() => {
const ids = new Set<string>();
visibleCollapsedParts.forEach((p) => {
if (p.part.id) ids.add(p.part.id);
});
return ids;
}, [visibleCollapsedParts]);
// Connections for collapsed view (based on visible parts only)
const collapsedToolConnections = React.useMemo(() => {
return getToolConnections(visibleCollapsedParts);
}, [visibleCollapsedParts]);
const hiddenCount = Math.max(0, displayParts.length - MAX_VISIBLE_COLLAPSED);
if (displayParts.length === 0) { if (displayParts.length === 0) {
return null; return null;
} }
const partsToRender = isExpanded ? displayParts : visibleCollapsedParts;
const connectionsToUse = isExpanded ? toolConnections : collapsedToolConnections;
// If there are no hidden items, header is not interactive
const isHeaderInteractive = hiddenCount > 0;
return ( return (
<FadeInOnReveal> <FadeInOnReveal>
<div className="my-1"> <div className="my-1">
{}
<div <div
className={cn( className={cn(
'group/tool flex items-center gap-2 pr-2 pl-px pt-0 pb-1.5 rounded-xl cursor-pointer' 'group/tool flex items-center gap-2 pr-2 pl-px pt-0 pb-1.5 rounded-xl',
isHeaderInteractive && 'cursor-pointer'
)} )}
onClick={onToggle} onClick={isHeaderInteractive ? onToggle : undefined}
> >
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{}
<div className="relative h-3.5 w-3.5 flex-shrink-0"> <div className="relative h-3.5 w-3.5 flex-shrink-0">
{} {isHeaderInteractive ? (
<>
<div <div
className={cn( className={cn(
'absolute inset-0 transition-opacity', 'absolute inset-0 transition-opacity',
@@ -162,7 +157,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
> >
<RiStackLine className="h-3.5 w-3.5" /> <RiStackLine className="h-3.5 w-3.5" />
</div> </div>
{}
<div <div
className={cn( className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center', 'absolute inset-0 transition-opacity flex items-center justify-center',
@@ -177,16 +171,16 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<RiArrowRightSLine className="h-3.5 w-3.5" /> <RiArrowRightSLine className="h-3.5 w-3.5" />
)} )}
</div> </div>
</>
) : (
<RiStackLine className="h-3.5 w-3.5" />
)}
</div> </div>
<span className="typography-meta font-medium">Activity</span> <span className="typography-meta font-medium">Activity</span>
</div> </div>
{(summary || diffStats) && (
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70 flex items-center gap-2">
{summary && (
<span className="truncate block">{summary}</span>
)}
{diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && ( {diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && (
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70 flex items-center gap-2">
<span className="flex-shrink-0 leading-none"> <span className="flex-shrink-0 leading-none">
<span className="text-[color:var(--status-success)]"> <span className="text-[color:var(--status-success)]">
+{Math.max(0, diffStats.additions)} +{Math.max(0, diffStats.additions)}
@@ -196,13 +190,10 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
-{Math.max(0, diffStats.deletions)} -{Math.max(0, diffStats.deletions)}
</span> </span>
</span> </span>
)}
</div> </div>
)} )}
</div> </div>
{}
{isExpanded && (
<div <div
className={cn( className={cn(
'relative pr-2 pb-1 pt-1 pl-[1.4375rem]', 'relative pr-2 pb-1 pt-1 pl-[1.4375rem]',
@@ -210,16 +201,31 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
'before:top-[-0.25rem] before:bottom-0' 'before:top-[-0.25rem] before:bottom-0'
)} )}
> >
{displayParts.map((activity, index) => { {!isExpanded && hiddenCount > 0 && (
<div
className="typography-micro text-muted-foreground/70 mb-1 cursor-pointer hover:text-muted-foreground"
onClick={onToggle}
>
+{hiddenCount} more...
</div>
)}
{partsToRender.map((activity, index) => {
const partId = activity.part.id || `group-part-${index}`; const partId = activity.part.id || `group-part-${index}`;
const connection = toolConnections[partId]; const connection = connectionsToUse[partId];
const animationKey = `${partId}-exp${expansionKey}`; const animationKey = `${partId}-exp${expansionKey}`;
// Skip animation if:
// - We just expanded from collapsed AND
// - This part was already visible in collapsed state
const wasVisibleInCollapsed = activity.part.id ? visibleInCollapsedIds.has(activity.part.id) : false;
const skipAnimation = justExpandedFromCollapsed && wasVisibleInCollapsed;
switch (activity.kind) { switch (activity.kind) {
case 'tool': case 'tool':
return ( return (
<FadeInOnReveal key={animationKey}> <FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
<ToolPart <ToolPart
part={activity.part as ToolPartType} part={activity.part as ToolPartType}
isExpanded={expandedTools.has(partId)} isExpanded={expandedTools.has(partId)}
@@ -235,7 +241,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
case 'reasoning': case 'reasoning':
return ( return (
<FadeInOnReveal key={animationKey}> <FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
<ReasoningPart <ReasoningPart
part={activity.part} part={activity.part}
messageId={activity.messageId} messageId={activity.messageId}
@@ -246,7 +252,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
case 'justification': case 'justification':
return ( return (
<FadeInOnReveal key={animationKey}> <FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
<JustificationBlock <JustificationBlock
part={activity.part} part={activity.part}
messageId={activity.messageId} messageId={activity.messageId}
@@ -260,7 +266,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
} }
})} })}
</div> </div>
)}
</div> </div>
</FadeInOnReveal> </FadeInOnReveal>
); );
@@ -42,8 +42,6 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
isZombie: memoryState?.isZombie || false, isZombie: memoryState?.isZombie || false,
backgroundCount: memoryState?.backgroundMessageCount || 0, backgroundCount: memoryState?.backgroundMessageCount || 0,
lastAccessed: memoryState?.lastAccessedAt || 0, lastAccessed: memoryState?.lastAccessedAt || 0,
activeTurnAnchorId: memoryState?.activeTurnAnchorId ?? null,
activeTurnSpacerHeight: memoryState?.activeTurnSpacerHeight ?? 0,
isCurrent: session.id === currentSessionId isCurrent: session.id === currentSessionId
}; };
}).sort((a, b) => b.lastAccessed - a.lastAccessed); }).sort((a, b) => b.lastAccessed - a.lastAccessed);
@@ -145,11 +143,6 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
}`}> }`}>
{stat.messageCount} msgs {stat.messageCount} msgs
</span> </span>
{stat.activeTurnAnchorId && stat.activeTurnSpacerHeight > 0 && (
<span className="font-mono text-xs text-primary">
anchor+{Math.round(stat.activeTurnSpacerHeight)}px
</span>
)}
{stat.backgroundCount > 0 && ( {stat.backgroundCount > 0 && (
<span className="text-primary">+{stat.backgroundCount}</span> <span className="text-primary">+{stat.backgroundCount}</span>
)} )}
+145 -399
View File
@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { flushSync } from 'react-dom';
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import { MessageFreshnessDetector } from '@/lib/messageFreshness'; import { MessageFreshnessDetector } from '@/lib/messageFreshness';
@@ -26,7 +25,6 @@ interface SessionMemoryState {
isZombie?: boolean; isZombie?: boolean;
} }
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
interface UseChatScrollManagerOptions { interface UseChatScrollManagerOptions {
currentSessionId: string | null; currentSessionId: string | null;
@@ -35,13 +33,10 @@ interface UseChatScrollManagerOptions {
streamingMessageId: string | null; streamingMessageId: string | null;
sessionMemoryState: Map<string, SessionMemoryState>; sessionMemoryState: Map<string, SessionMemoryState>;
updateViewportAnchor: (sessionId: string, anchor: number) => void; updateViewportAnchor: (sessionId: string, anchor: number) => void;
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => void;
getActiveTurnAnchor: (sessionId: string) => { anchorId: string | null; spacerHeight: number } | null;
isSyncing: boolean; isSyncing: boolean;
isMobile: boolean; isMobile: boolean;
messageStreamStates: Map<string, unknown>; messageStreamStates: Map<string, unknown>;
trimToViewportWindow: (sessionId: string, targetSize?: number) => void; trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
sessionActivityPhase?: Map<string, SessionActivityPhase>;
} }
export interface AnimationHandlers { export interface AnimationHandlers {
@@ -59,128 +54,76 @@ interface UseChatScrollManagerResult {
handleMessageContentChange: (reason?: ContentChangeReason) => void; handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers; getAnimationHandlers: (messageId: string) => AnimationHandlers;
showScrollButton: boolean; showScrollButton: boolean;
scrollToBottom: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void; scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
spacerHeight: number; scrollToPosition: (position: number, options?: { instant?: boolean }) => void;
pendingAnchorId: string | null; isPinned: boolean;
hasActiveAnchor: boolean;
} }
const ANCHOR_TARGET_OFFSET = 8;
const DEFAULT_SCROLL_BUTTON_THRESHOLD = 40;
const NEW_USER_ANCHOR_WINDOW_MS = 20_000;
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200; const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
// After we set an anchor/spacer, ignore incidental scroll events for a bit.
const ANCHOR_CLEAR_GRACE_MS = 1200;
// Require recent direct user input (wheel/touch) to treat scroll as intentional.
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250; const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
const ANCHOR_CLEAR_TOLERANCE_PX = 24; // Threshold for re-pinning: 10% of container height (matches bottom spacer)
const PIN_THRESHOLD_RATIO = 0.10;
const getMessageId = (message: ChatMessageRecord): string | null => {
const info = message.info;
if (typeof info?.id === 'string') {
return info.id;
}
return null;
};
const isUserMessage = (message: ChatMessageRecord): boolean => {
const info = message.info;
if (info?.userMessageMarker === true) {
return true;
}
const clientRole = info?.clientRole;
const serverRole = info?.role;
return clientRole === 'user' || serverRole === 'user';
};
const getMessageCreatedAt = (message: ChatMessageRecord): number => {
const info = message.info as { time?: { created?: unknown } };
const created = info?.time?.created;
return typeof created === 'number' ? created : 0;
};
export const useChatScrollManager = ({ export const useChatScrollManager = ({
currentSessionId, currentSessionId,
sessionMessages, sessionMessages,
streamingMessageId,
updateViewportAnchor, updateViewportAnchor,
updateActiveTurnAnchor,
getActiveTurnAnchor,
isSyncing, isSyncing,
isMobile, isMobile,
sessionActivityPhase,
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => { }: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null); const scrollRef = React.useRef<HTMLDivElement | null>(null);
const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile }); const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile });
const [anchorId, setAnchorId] = React.useState<string | null>(null); const getPinThreshold = React.useCallback(() => {
const [spacerHeight, setSpacerHeight] = React.useState(0); const container = scrollRef.current;
const [showScrollButton, setShowScrollButton] = React.useState(false); if (!container || container.clientHeight <= 0) {
const [pendingAnchorId, setPendingAnchorId] = React.useState<string | null>(null); return 0;
}
const raw = container.clientHeight * PIN_THRESHOLD_RATIO;
return Math.max(24, Math.min(200, raw));
}, []);
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [isPinned, setIsPinned] = React.useState(true);
const lastScrolledAnchorIdRef = React.useRef<string | null>(null);
const lastSessionIdRef = React.useRef<string | null>(null); const lastSessionIdRef = React.useRef<string | null>(null);
const currentSessionIdRef = React.useRef<string | null>(currentSessionId ?? null); const currentSessionIdRef = React.useRef<string | null>(currentSessionId ?? null);
const suppressUserScrollUntilRef = React.useRef<number>(0); const suppressUserScrollUntilRef = React.useRef<number>(0);
const anchorClearIgnoreUntilRef = React.useRef<number>(0);
const lastDirectScrollIntentAtRef = React.useRef<number>(0); const lastDirectScrollIntentAtRef = React.useRef<number>(0);
const previousMessageIdsRef = React.useRef<Set<string>>(new Set()); const isPinnedRef = React.useRef(true);
const lastMessageCountRef = React.useRef<number>(sessionMessages.length); const lastScrollTopRef = React.useRef<number>(0);
const spacerHeightRef = React.useRef(0);
const anchorIdRef = React.useRef<string | null>(null);
const pendingRestoreAnchorRef = React.useRef<{ sessionId: string; anchorId: string; startedAt: number } | null>(null);
const userScrollOverrideRef = React.useRef<boolean>(false);
const currentPhase = currentSessionId
? sessionActivityPhase?.get(currentSessionId) ?? 'idle'
: 'idle';
const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown';
React.useEffect(() => { React.useEffect(() => {
currentSessionIdRef.current = currentSessionId ?? null; currentSessionIdRef.current = currentSessionId ?? null;
}, [currentSessionId]); }, [currentSessionId]);
const updateSpacerHeight = React.useCallback((height: number) => {
const newHeight = Math.max(0, height);
if (spacerHeightRef.current !== newHeight) {
spacerHeightRef.current = newHeight;
setSpacerHeight(newHeight);
}
}, []);
const calculateAnchorPosition = React.useCallback((anchorElement: HTMLElement): number => {
const messageTop = anchorElement.offsetTop;
return messageTop - ANCHOR_TARGET_OFFSET;
}, []);
const isAnchorStillPinned = React.useCallback((): boolean => {
const container = scrollRef.current;
const anchorId = anchorIdRef.current;
if (!container || !anchorId) return false;
const anchorElement = container.querySelector(`[data-message-id="${anchorId}"]`) as HTMLElement | null;
if (!anchorElement) return false;
const expectedTop = calculateAnchorPosition(anchorElement);
const distance = Math.abs(container.scrollTop - expectedTop);
return distance <= ANCHOR_CLEAR_TOLERANCE_PX;
}, [calculateAnchorPosition]);
const clearActiveTurnAnchor = React.useCallback((sessionId: string) => {
anchorIdRef.current = null;
lastScrolledAnchorIdRef.current = null;
pendingRestoreAnchorRef.current = null;
setAnchorId(null);
updateSpacerHeight(0);
updateActiveTurnAnchor(sessionId, null, 0);
}, [updateActiveTurnAnchor, updateSpacerHeight]);
const markProgrammaticScroll = React.useCallback(() => { const markProgrammaticScroll = React.useCallback(() => {
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS; suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
}, []); }, []);
const getDistanceFromBottom = React.useCallback(() => {
const container = scrollRef.current;
if (!container) return 0;
return container.scrollHeight - container.scrollTop - container.clientHeight;
}, []);
const updatePinnedState = React.useCallback((newPinned: boolean) => {
if (isPinnedRef.current !== newPinned) {
isPinnedRef.current = newPinned;
setIsPinned(newPinned);
}
}, []);
const scrollToBottomInternal = React.useCallback((options?: { instant?: boolean; followBottom?: boolean }) => {
const container = scrollRef.current;
if (!container) return;
const bottom = container.scrollHeight - container.clientHeight;
markProgrammaticScroll();
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
}, [markProgrammaticScroll, scrollEngine]);
const updateScrollButtonVisibility = React.useCallback(() => { const updateScrollButtonVisibility = React.useCallback(() => {
const container = scrollRef.current; const container = scrollRef.current;
if (!container) { if (!container) {
@@ -188,126 +131,35 @@ export const useChatScrollManager = ({
return; return;
} }
if (pendingAnchorId) {
setShowScrollButton(false);
return;
}
const hasScrollableContent = container.scrollHeight > container.clientHeight; const hasScrollableContent = container.scrollHeight > container.clientHeight;
if (!hasScrollableContent) { if (!hasScrollableContent) {
setShowScrollButton(false); setShowScrollButton(false);
return; return;
} }
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; // Show scroll button when scrolled above the 10vh threshold
const currentSpacerHeight = spacerHeightRef.current; const distanceFromBottom = getDistanceFromBottom();
setShowScrollButton(distanceFromBottom > getPinThreshold());
}, [getDistanceFromBottom, getPinThreshold]);
if (currentSpacerHeight > 0) { const scrollToPosition = React.useCallback((position: number, options?: { instant?: boolean }) => {
const spacerStartPosition = container.scrollHeight - currentSpacerHeight;
const viewportBottom = container.scrollTop + container.clientHeight;
setShowScrollButton(viewportBottom < spacerStartPosition);
} else {
setShowScrollButton(distanceFromBottom > DEFAULT_SCROLL_BUTTON_THRESHOLD);
}
}, [pendingAnchorId]);
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => {
const container = scrollRef.current; const container = scrollRef.current;
if (!container) return; if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const shouldRespectUserScroll =
userScrollOverrideRef.current &&
currentPhase === 'idle' &&
!isSyncing &&
!options?.force &&
distanceFromBottom > DEFAULT_SCROLL_BUTTON_THRESHOLD;
if (shouldRespectUserScroll) {
return;
}
if (options?.force) {
userScrollOverrideRef.current = false;
}
if (options?.clearAnchor && currentSessionId && anchorIdRef.current) {
clearActiveTurnAnchor(currentSessionId);
}
const bottom = container.scrollHeight - container.clientHeight;
markProgrammaticScroll(); markProgrammaticScroll();
scrollEngine.scrollToPosition(Math.max(0, bottom), options); scrollEngine.scrollToPosition(Math.max(0, position), options);
}, [clearActiveTurnAnchor, currentPhase, currentSessionId, isSyncing, markProgrammaticScroll, scrollEngine]); }, [markProgrammaticScroll, scrollEngine]);
const scrollToNewAnchor = React.useCallback((messageId: string) => {
if (lastScrolledAnchorIdRef.current === messageId) {
return;
}
lastScrolledAnchorIdRef.current = messageId;
// Give the UI a grace window so incidental scroll/layout events don't clear the anchor.
anchorClearIgnoreUntilRef.current = Date.now() + ANCHOR_CLEAR_GRACE_MS;
setPendingAnchorId(messageId);
const expectedSessionId = currentSessionIdRef.current;
window.requestAnimationFrame(() => {
if (expectedSessionId !== currentSessionIdRef.current) {
return;
}
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean }) => {
const container = scrollRef.current; const container = scrollRef.current;
if (!container) { if (!container) return;
setPendingAnchorId(null);
return;
}
const anchorElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null; // Re-pin when explicitly scrolling to bottom
if (!anchorElement) { updatePinnedState(true);
setPendingAnchorId(null);
return;
}
const containerHeight = container.clientHeight; scrollToBottomInternal(options);
const targetScrollTop = calculateAnchorPosition(anchorElement); setShowScrollButton(false);
}, [scrollToBottomInternal, updatePinnedState]);
const contentHeight = container.scrollHeight;
const currentSpacer = spacerHeightRef.current;
const contentWithoutSpacer = contentHeight - currentSpacer;
const requiredHeight = targetScrollTop + containerHeight;
let newSpacerHeight = 0;
if (contentWithoutSpacer < requiredHeight) {
newSpacerHeight = requiredHeight - contentWithoutSpacer;
}
if (newSpacerHeight !== currentSpacer) {
updateSpacerHeight(newSpacerHeight);
}
if (currentSessionIdRef.current) {
updateActiveTurnAnchor(currentSessionIdRef.current, messageId, newSpacerHeight);
}
window.requestAnimationFrame(() => {
if (expectedSessionId !== currentSessionIdRef.current) {
return;
}
markProgrammaticScroll();
scrollEngine.scrollToPosition(Math.max(0, targetScrollTop), { instant: true });
window.requestAnimationFrame(() => {
if (expectedSessionId !== currentSessionIdRef.current) {
return;
}
setPendingAnchorId(null);
});
});
});
}, [calculateAnchorPosition, markProgrammaticScroll, scrollEngine, updateActiveTurnAnchor, updateSpacerHeight]);
const handleScrollEvent = React.useCallback((event?: Event) => { const handleScrollEvent = React.useCallback((event?: Event) => {
const container = scrollRef.current; const container = scrollRef.current;
@@ -316,46 +168,44 @@ export const useChatScrollManager = ({
} }
const now = Date.now(); const now = Date.now();
const isProgrammatic = now < suppressUserScrollUntilRef.current || pendingAnchorId !== null; const isProgrammatic = now < suppressUserScrollUntilRef.current;
const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS; const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS;
if (event?.isTrusted && !isProgrammatic && hasDirectIntent) {
userScrollOverrideRef.current = true;
}
scrollEngine.handleScroll(); scrollEngine.handleScroll();
updateScrollButtonVisibility(); updateScrollButtonVisibility();
const shouldIgnoreAnchorClear = now < anchorClearIgnoreUntilRef.current; // Handle pin/unpin logic
const currentScrollTop = container.scrollTop;
if ( // Unpin requires strict user intent check
event?.isTrusted && if (event?.isTrusted && !isProgrammatic && hasDirectIntent) {
!isProgrammatic && const scrollingUp = currentScrollTop < lastScrollTopRef.current;
!shouldIgnoreAnchorClear && if (scrollingUp && isPinnedRef.current) {
hasDirectIntent && updatePinnedState(false);
currentPhase === 'idle' &&
anchorIdRef.current !== null &&
spacerHeightRef.current > 0 &&
// Only clear when the user actually scrolls away from the pinned anchor.
// (Spacer being out of viewport is expected while anchored.)
!isAnchorStillPinned()
) {
clearActiveTurnAnchor(currentSessionId);
} }
}
// Re-pin at bottom should always work (even momentum scroll)
if (!isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom <= getPinThreshold()) {
updatePinnedState(true);
}
}
lastScrollTopRef.current = currentScrollTop;
const { scrollTop, scrollHeight, clientHeight } = container; const { scrollTop, scrollHeight, clientHeight } = container;
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1); const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
const estimatedIndex = Math.floor(position * sessionMessages.length); const estimatedIndex = Math.floor(position * sessionMessages.length);
updateViewportAnchor(currentSessionId, estimatedIndex); updateViewportAnchor(currentSessionId, estimatedIndex);
}, [ }, [
clearActiveTurnAnchor,
currentPhase,
currentSessionId, currentSessionId,
isAnchorStillPinned, getDistanceFromBottom,
pendingAnchorId, getPinThreshold,
scrollEngine, scrollEngine,
sessionMessages.length, sessionMessages.length,
updatePinnedState,
updateScrollButtonVisibility, updateScrollButtonVisibility,
updateViewportAnchor, updateViewportAnchor,
]); ]);
@@ -379,6 +229,7 @@ export const useChatScrollManager = ({
}; };
}, [handleScrollEvent]); }, [handleScrollEvent]);
// Session switch - always start pinned at bottom
useIsomorphicLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) { if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
return; return;
@@ -387,175 +238,69 @@ export const useChatScrollManager = ({
lastSessionIdRef.current = currentSessionId; lastSessionIdRef.current = currentSessionId;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId); MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
previousMessageIdsRef.current = new Set( // Always start pinned at bottom on session switch
sessionMessages.map(getMessageId).filter((id): id is string => Boolean(id)) updatePinnedState(true);
);
lastMessageCountRef.current = sessionMessages.length;
if (isActivePhase) {
const persistedAnchor = getActiveTurnAnchor(currentSessionId);
if (persistedAnchor && persistedAnchor.anchorId) {
anchorIdRef.current = persistedAnchor.anchorId;
lastScrolledAnchorIdRef.current = persistedAnchor.anchorId;
const container = scrollRef.current;
const anchorElement = container
? (container.querySelector(`[data-message-id="${persistedAnchor.anchorId}"]`) as HTMLElement | null)
: null;
const messageHeight = anchorElement?.offsetHeight ?? 0;
const restoredSpacerHeight = Math.max(0, persistedAnchor.spacerHeight - (messageHeight - 50));
flushSync(() => {
setAnchorId(persistedAnchor.anchorId);
updateSpacerHeight(restoredSpacerHeight);
});
pendingRestoreAnchorRef.current = { sessionId: currentSessionId, anchorId: persistedAnchor.anchorId, startedAt: Date.now() };
} else {
lastScrolledAnchorIdRef.current = null;
anchorIdRef.current = null;
setAnchorId(null);
updateSpacerHeight(0);
pendingRestoreAnchorRef.current = null;
const container = scrollRef.current;
if (container) {
const bottom = container.scrollHeight - container.clientHeight;
markProgrammaticScroll();
scrollEngine.scrollToPosition(Math.max(0, bottom), { instant: true });
}
}
} else {
lastScrolledAnchorIdRef.current = null;
anchorIdRef.current = null;
setAnchorId(null);
updateSpacerHeight(0);
pendingRestoreAnchorRef.current = null;
updateActiveTurnAnchor(currentSessionId, null, 0);
const container = scrollRef.current;
if (container) {
const bottom = container.scrollHeight - container.clientHeight;
markProgrammaticScroll();
scrollEngine.scrollToPosition(Math.max(0, bottom), { instant: true });
}
}
setPendingAnchorId(null);
setShowScrollButton(false); setShowScrollButton(false);
userScrollOverrideRef.current = false;
}, [
currentSessionId,
getActiveTurnAnchor,
isActivePhase,
markProgrammaticScroll,
scrollEngine,
updateActiveTurnAnchor,
updateSpacerHeight,
sessionMessages,
]);
useIsomorphicLayoutEffect(() => { const container = scrollRef.current;
if (typeof window === 'undefined') return; if (container) {
if (!currentSessionId) return; markProgrammaticScroll();
scrollToBottomInternal({ instant: true });
}
}, [currentSessionId, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]);
const pending = pendingRestoreAnchorRef.current; // Maintain pin-to-bottom when content changes
if (!pending || pending.sessionId !== currentSessionId) return; React.useEffect(() => {
if (!isPinnedRef.current) return;
if (isSyncing) return;
const container = scrollRef.current; const container = scrollRef.current;
if (!container) return; if (!container) return;
const anchorElement = container.querySelector(`[data-message-id="${pending.anchorId}"]`) as HTMLElement | null; // When pinned and content grows, scroll to bottom instantly
if (!anchorElement) { const distanceFromBottom = getDistanceFromBottom();
// When the anchor is created from a just-sent user message, the persisted anchor can if (distanceFromBottom > getPinThreshold()) {
// show up before the message is in the rendered list. Give it a short window.
if (Date.now() - pending.startedAt < 1200) {
return;
}
clearActiveTurnAnchor(currentSessionId);
return;
}
const targetScrollTop = calculateAnchorPosition(anchorElement);
markProgrammaticScroll(); markProgrammaticScroll();
scrollEngine.scrollToPosition(targetScrollTop, { instant: true }); scrollToBottomInternal({ instant: true });
pendingRestoreAnchorRef.current = null;
}, [calculateAnchorPosition, clearActiveTurnAnchor, currentSessionId, markProgrammaticScroll, scrollEngine, sessionMessages]);
useIsomorphicLayoutEffect(() => {
if (isSyncing) {
return;
} }
}, [getDistanceFromBottom, getPinThreshold, isSyncing, markProgrammaticScroll, scrollToBottomInternal, sessionMessages]);
if (lastSessionIdRef.current !== currentSessionId) { // Use ResizeObserver to detect content changes and maintain pin
return;
}
const previousIds = previousMessageIdsRef.current;
const nextIds = new Set(sessionMessages.map(getMessageId).filter((id): id is string => Boolean(id)));
const nextCount = sessionMessages.length;
if (nextCount > lastMessageCountRef.current) {
const addedIds: string[] = [];
nextIds.forEach((id) => {
if (!previousIds.has(id)) {
addedIds.push(id);
}
});
if (addedIds.length > 0) {
const now = Date.now();
let latestNewUserMessageId: string | null = null;
let latestNewUserCreatedAt = 0;
for (let i = 0; i < sessionMessages.length; i++) {
const message = sessionMessages[i];
const id = getMessageId(message);
if (!id || !addedIds.includes(id)) continue;
if (!isUserMessage(message)) continue;
let createdAt = getMessageCreatedAt(message);
if (createdAt <= 0 && (Boolean(streamingMessageId) || isActivePhase)) {
createdAt = now;
}
if (createdAt >= latestNewUserCreatedAt) {
latestNewUserCreatedAt = createdAt;
latestNewUserMessageId = id;
}
}
const shouldAnchorNewUser =
latestNewUserMessageId !== null &&
(Boolean(streamingMessageId) ||
isActivePhase ||
now - latestNewUserCreatedAt <= NEW_USER_ANCHOR_WINDOW_MS);
if (shouldAnchorNewUser && latestNewUserMessageId) {
anchorIdRef.current = latestNewUserMessageId;
setAnchorId(latestNewUserMessageId);
scrollToNewAnchor(latestNewUserMessageId);
}
}
}
lastMessageCountRef.current = nextCount;
previousMessageIdsRef.current = nextIds;
}, [currentSessionId, isActivePhase, isSyncing, scrollToNewAnchor, sessionMessages, streamingMessageId]);
React.useEffect(() => { React.useEffect(() => {
const container = scrollRef.current; const container = scrollRef.current;
if (!container || typeof ResizeObserver === 'undefined') return; if (!container || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
updateScrollButtonVisibility(); updateScrollButtonVisibility();
// Maintain pin when content grows - always instant for smooth experience
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true });
}
}
}); });
observer.observe(container); observer.observe(container);
// Also observe children for content changes
const childObserver = new MutationObserver(() => {
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true });
}
}
});
childObserver.observe(container, { childList: true, subtree: true });
return () => { return () => {
observer.disconnect(); observer.disconnect();
childObserver.disconnect();
}; };
}, [updateScrollButtonVisibility]); }, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -572,21 +317,19 @@ export const useChatScrollManager = ({
}; };
}, [currentSessionId, sessionMessages.length, updateScrollButtonVisibility]); }, [currentSessionId, sessionMessages.length, updateScrollButtonVisibility]);
React.useEffect(() => {
if (anchorId) {
updateScrollButtonVisibility();
}
}, [anchorId, updateScrollButtonVisibility]);
React.useEffect(() => {
updateScrollButtonVisibility();
}, [spacerHeight, updateScrollButtonVisibility]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map()); const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
const handleMessageContentChange = React.useCallback(() => { const handleMessageContentChange = React.useCallback(() => {
updateScrollButtonVisibility(); updateScrollButtonVisibility();
}, [updateScrollButtonVisibility]);
// Maintain pin when content changes - always instant
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true });
}
}
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => { const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
const existing = animationHandlersRef.current.get(messageId); const existing = animationHandlersRef.current.get(messageId);
@@ -597,30 +340,34 @@ export const useChatScrollManager = ({
const handlers: AnimationHandlers = { const handlers: AnimationHandlers = {
onChunk: () => { onChunk: () => {
updateScrollButtonVisibility(); updateScrollButtonVisibility();
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true });
}
}
}, },
onComplete: () => { onComplete: () => {
updateScrollButtonVisibility(); updateScrollButtonVisibility();
}, },
onStreamingCandidate: () => { onStreamingCandidate: () => {},
onAnimationStart: () => {},
},
onAnimationStart: () => {
},
onAnimatedHeightChange: () => { onAnimatedHeightChange: () => {
updateScrollButtonVisibility(); updateScrollButtonVisibility();
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true });
}
}
}, },
onReservationCancelled: () => { onReservationCancelled: () => {},
onReasoningBlock: () => {},
},
onReasoningBlock: () => {
},
}; };
animationHandlersRef.current.set(messageId, handlers); animationHandlersRef.current.set(messageId, handlers);
return handlers; return handlers;
}, [updateScrollButtonVisibility]); }, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
return { return {
scrollRef, scrollRef,
@@ -628,8 +375,7 @@ export const useChatScrollManager = ({
getAnimationHandlers, getAnimationHandlers,
showScrollButton, showScrollButton,
scrollToBottom, scrollToBottom,
spacerHeight, scrollToPosition,
pendingAnchorId, isPinned,
hasActiveAnchor: anchorId !== null,
}; };
}; };
+7 -6
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client'; import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client';
import { saveSessionCursor } from '@/lib/messageCursorPersistence'; import { saveSessionCursor } from '@/lib/messageCursorPersistence';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { getActiveSessionWindow } from '@/stores/types/sessionTypes';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore'; import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -276,7 +277,7 @@ export const useEventStream = () => {
); );
const resyncMessages = React.useCallback( const resyncMessages = React.useCallback(
(sessionId: string, reason: string) => { (sessionId: string, reason: string, limit?: number) => {
if (!sessionId) { if (!sessionId) {
return Promise.resolve(); return Promise.resolve();
} }
@@ -287,7 +288,7 @@ export const useEventStream = () => {
if (now - lastResyncAtRef.current < RESYNC_DEBOUNCE_MS) { if (now - lastResyncAtRef.current < RESYNC_DEBOUNCE_MS) {
return Promise.resolve(); return Promise.resolve();
} }
const task = loadMessages(sessionId) const task = loadMessages(sessionId, limit)
.catch((error) => { .catch((error) => {
console.warn(`[useEventStream] Failed to resync messages (${reason}):`, error); console.warn(`[useEventStream] Failed to resync messages (${reason}):`, error);
}) })
@@ -309,7 +310,7 @@ export const useEventStream = () => {
try { try {
await Promise.all([ await Promise.all([
loadSessions(), loadSessions(),
currentSessionId ? resyncMessages(currentSessionId, reason) : Promise.resolve(), currentSessionId ? resyncMessages(currentSessionId, reason, Infinity) : Promise.resolve(),
]); ]);
} catch (error) { } catch (error) {
console.warn('[useEventStream] Bootstrap failed:', reason, error); console.warn('[useEventStream] Bootstrap failed:', reason, error);
@@ -1451,7 +1452,7 @@ export const useEventStream = () => {
const sessionId = currentSessionIdRef.current; const sessionId = currentSessionIdRef.current;
if (sessionId) { if (sessionId) {
setTimeout(() => { setTimeout(() => {
resyncMessages(sessionId, 'sse_reconnected') resyncMessages(sessionId, 'sse_reconnected', Infinity)
.then(() => requestSessionMetadataRefresh(sessionId)) .then(() => requestSessionMetadataRefresh(sessionId))
.catch((error) => { .catch((error) => {
console.warn('[useEventStream] Failed to resync messages after reconnect:', error); console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
@@ -1618,7 +1619,7 @@ export const useEventStream = () => {
console.info('[useEventStream] Visibility restored, triggering soft refresh...'); console.info('[useEventStream] Visibility restored, triggering soft refresh...');
const sessionId = currentSessionIdRef.current; const sessionId = currentSessionIdRef.current;
if (sessionId) { if (sessionId) {
resyncMessages(sessionId, 'visibility_restore').catch(() => {}); resyncMessages(sessionId, 'visibility_restore', getActiveSessionWindow()).catch(() => {});
requestSessionMetadataRefresh(sessionId); requestSessionMetadataRefresh(sessionId);
} }
@@ -1644,7 +1645,7 @@ export const useEventStream = () => {
const sessionId = currentSessionIdRef.current; const sessionId = currentSessionIdRef.current;
if (sessionId) { if (sessionId) {
requestSessionMetadataRefresh(sessionId); requestSessionMetadataRefresh(sessionId);
resyncMessages(sessionId, 'window_focus') resyncMessages(sessionId, 'window_focus', getActiveSessionWindow())
.then(() => console.info('[useEventStream] Messages refreshed on focus')) .then(() => console.info('[useEventStream] Messages refreshed on focus'))
.catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err)); .catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err));
} }
+15
View File
@@ -7,6 +7,7 @@ type ScrollEngineOptions = {
type ScrollOptions = { type ScrollOptions = {
instant?: boolean; instant?: boolean;
followBottom?: boolean; // Dynamically track bottom during animation
}; };
type ScrollEngineResult = { type ScrollEngineResult = {
@@ -33,6 +34,7 @@ export const useScrollEngine = ({
const animationStartRef = React.useRef<number | null>(null); const animationStartRef = React.useRef<number | null>(null);
const animationFromRef = React.useRef(0); const animationFromRef = React.useRef(0);
const animationTargetRef = React.useRef(0); const animationTargetRef = React.useRef(0);
const followBottomRef = React.useRef(false);
const cancelAnimation = React.useCallback(() => { const cancelAnimation = React.useCallback(() => {
if (animationFrameRef.current !== null && typeof window !== 'undefined') { if (animationFrameRef.current !== null && typeof window !== 'undefined') {
@@ -41,6 +43,7 @@ export const useScrollEngine = ({
animationFrameRef.current = null; animationFrameRef.current = null;
animationStartRef.current = null; animationStartRef.current = null;
followBottomRef.current = false;
}, []); }, []);
const runAnimationFrame = React.useCallback( const runAnimationFrame = React.useCallback(
@@ -55,6 +58,11 @@ export const useScrollEngine = ({
animationStartRef.current = timestamp; animationStartRef.current = timestamp;
} }
// If followBottom mode, dynamically update target to current bottom
if (followBottomRef.current) {
animationTargetRef.current = container.scrollHeight - container.clientHeight;
}
const progress = Math.min(1, (timestamp - animationStartRef.current) / ANIMATION_DURATION_MS); const progress = Math.min(1, (timestamp - animationStartRef.current) / ANIMATION_DURATION_MS);
const easedProgress = 1 - Math.pow(1 - progress, 3); const easedProgress = 1 - Math.pow(1 - progress, 3);
const from = animationFromRef.current; const from = animationFromRef.current;
@@ -86,6 +94,7 @@ export const useScrollEngine = ({
const target = Math.max(0, position); const target = Math.max(0, position);
const preferInstant = options?.instant ?? false; const preferInstant = options?.instant ?? false;
const followBottom = options?.followBottom ?? false;
manualOverrideRef.current = false; manualOverrideRef.current = false;
@@ -102,6 +111,11 @@ export const useScrollEngine = ({
return; return;
} }
// If followBottom animation is already running, don't restart - let it continue
if (followBottom && followBottomRef.current && animationFrameRef.current !== null) {
return;
}
cancelAnimation(); cancelAnimation();
const distance = Math.abs(target - container.scrollTop); const distance = Math.abs(target - container.scrollTop);
@@ -120,6 +134,7 @@ export const useScrollEngine = ({
animationFromRef.current = container.scrollTop; animationFromRef.current = container.scrollTop;
animationTargetRef.current = target; animationTargetRef.current = target;
animationStartRef.current = null; animationStartRef.current = null;
followBottomRef.current = followBottom;
animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame); animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame);
}, },
[cancelAnimation, containerRef, runAnimationFrame, setIsAtTop] [cancelAnimation, containerRef, runAnimationFrame, setIsAtTop]
+5 -35
View File
@@ -343,7 +343,7 @@ interface MessageState {
} }
interface MessageActions { interface MessageActions {
loadMessages: (sessionId: string) => Promise<void>; loadMessages: (sessionId: string, limit?: number) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>; sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>;
abortCurrentOperation: (currentSessionId?: string) => Promise<void>; abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
@@ -354,8 +354,6 @@ interface MessageActions {
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => void; updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => void;
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void; syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void;
updateViewportAnchor: (sessionId: string, anchor: number) => void; updateViewportAnchor: (sessionId: string, anchor: number) => void;
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => void;
getActiveTurnAnchor: (sessionId: string) => { anchorId: string | null; spacerHeight: number } | null;
trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => void; trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => void;
evictLeastRecentlyUsed: (currentSessionId?: string) => void; evictLeastRecentlyUsed: (currentSessionId?: string) => void;
loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>; loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>;
@@ -387,10 +385,12 @@ export const useMessageStore = create<MessageStore>()(
loadMessages: async (sessionId: string, limit?: number) => { loadMessages: async (sessionId: string, limit?: number) => {
const memLimits = getMemoryLimits(); const memLimits = getMemoryLimits();
const effectiveLimit = limit ?? memLimits.HISTORICAL_MESSAGES; const noLimit = limit === Infinity;
const effectiveLimit = noLimit ? Infinity : (limit ?? memLimits.HISTORICAL_MESSAGES);
const isStreaming = get().sessionMemoryState.get(sessionId)?.isStreaming; const isStreaming = get().sessionMemoryState.get(sessionId)?.isStreaming;
const targetLimit = isStreaming ? memLimits.VIEWPORT_MESSAGES : effectiveLimit; const targetLimit = isStreaming ? memLimits.VIEWPORT_MESSAGES : effectiveLimit;
const fetchLimit = isStreaming ? undefined : targetLimit + memLimits.FETCH_BUFFER; // Don't pass Infinity to API - use undefined for "fetch all"
const fetchLimit = isStreaming || noLimit ? undefined : targetLimit + memLimits.FETCH_BUFFER;
const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit)); const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit));
// Filter out reverted messages first // Filter out reverted messages first
@@ -2201,34 +2201,6 @@ export const useMessageStore = create<MessageStore>()(
}); });
}, },
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => {
set((state) => {
const memoryState = state.sessionMemoryState.get(sessionId) || {
viewportAnchor: 0,
isStreaming: false,
lastAccessedAt: Date.now(),
backgroundMessageCount: 0,
};
const newMemoryState = new Map(state.sessionMemoryState);
newMemoryState.set(sessionId, {
...memoryState,
activeTurnAnchorId: anchorId ?? undefined,
activeTurnSpacerHeight: spacerHeight,
});
return { sessionMemoryState: newMemoryState };
});
},
getActiveTurnAnchor: (sessionId: string) => {
const memoryState = get().sessionMemoryState.get(sessionId);
if (!memoryState) return null;
return {
anchorId: memoryState.activeTurnAnchorId ?? null,
spacerHeight: memoryState.activeTurnSpacerHeight ?? 0,
};
},
trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => { trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => {
const effectiveTargetSize = targetSize ?? getMemoryLimits().VIEWPORT_MESSAGES; const effectiveTargetSize = targetSize ?? getMemoryLimits().VIEWPORT_MESSAGES;
const state = get(); const state = get();
@@ -2502,8 +2474,6 @@ export const useMessageStore = create<MessageStore>()(
totalAvailableMessages: memory.totalAvailableMessages, totalAvailableMessages: memory.totalAvailableMessages,
hasMoreAbove: memory.hasMoreAbove, hasMoreAbove: memory.hasMoreAbove,
trimmedHeadMaxId: memory.trimmedHeadMaxId, trimmedHeadMaxId: memory.trimmedHeadMaxId,
activeTurnAnchorId: memory.activeTurnAnchorId,
activeTurnSpacerHeight: memory.activeTurnSpacerHeight,
}, },
]), ]),
sessionAbortFlags: Array.from(state.sessionAbortFlags.entries()).map(([sessionId, record]) => [ sessionAbortFlags: Array.from(state.sessionAbortFlags.entries()).map(([sessionId, record]) => [
+1 -7
View File
@@ -35,10 +35,6 @@ export interface SessionMemoryState {
hasMoreAbove?: boolean; hasMoreAbove?: boolean;
trimmedHeadMaxId?: string; trimmedHeadMaxId?: string;
streamingCooldownUntil?: number; streamingCooldownUntil?: number;
/** Message ID of the user's active turn anchor (for scroll position preservation) */
activeTurnAnchorId?: string;
/** Height of the spacer below messages for active turn positioning */
activeTurnSpacerHeight?: number;
} }
export interface SessionContextUsage { export interface SessionContextUsage {
@@ -163,7 +159,7 @@ export interface SessionStore {
shareSession: (id: string) => Promise<Session | null>; shareSession: (id: string) => Promise<Session | null>;
unshareSession: (id: string) => Promise<Session | null>; unshareSession: (id: string) => Promise<Session | null>;
setCurrentSession: (id: string | null) => void; setCurrentSession: (id: string | null) => void;
loadMessages: (sessionId: string) => Promise<void>; loadMessages: (sessionId: string, limit?: number) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>; sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>;
abortCurrentOperation: () => Promise<void>; abortCurrentOperation: () => Promise<void>;
acknowledgeSessionAbort: (sessionId: string) => void; acknowledgeSessionAbort: (sessionId: string) => void;
@@ -197,8 +193,6 @@ export interface SessionStore {
clearAttachedFiles: () => void; clearAttachedFiles: () => void;
updateViewportAnchor: (sessionId: string, anchor: number) => void; updateViewportAnchor: (sessionId: string, anchor: number) => void;
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => void;
getActiveTurnAnchor: (sessionId: string) => { anchorId: string | null; spacerHeight: number } | null;
trimToViewportWindow: (sessionId: string, targetSize?: number) => void; trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
evictLeastRecentlyUsed: () => void; evictLeastRecentlyUsed: () => void;
loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>; loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>;
+2 -4
View File
@@ -310,7 +310,7 @@ export const useSessionStore = create<SessionStore>()(
get().evictLeastRecentlyUsed(); get().evictLeastRecentlyUsed();
}, },
loadMessages: (sessionId: string) => useMessageStore.getState().loadMessages(sessionId), loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit),
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => { sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => {
const draft = get().newSessionDraft; const draft = get().newSessionDraft;
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined; const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
@@ -502,11 +502,9 @@ export const useSessionStore = create<SessionStore>()(
clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(), clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(),
updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor), updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor),
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => useMessageStore.getState().updateActiveTurnAnchor(sessionId, anchorId, spacerHeight),
getActiveTurnAnchor: (sessionId: string) => useMessageStore.getState().getActiveTurnAnchor(sessionId),
trimToViewportWindow: (sessionId: string, targetSize?: number) => { trimToViewportWindow: (sessionId: string, targetSize?: number) => {
const currentSessionId = useSessionManagementStore.getState().currentSessionId; const currentSessionId = useSessionManagementStore.getState().currentSessionId;
// Skip trimming for sessions in active phase (busy/cooldown) to preserve anchor/spacer // Skip trimming for sessions in active phase (busy/cooldown)
const phase = get().sessionActivityPhase?.get(sessionId); const phase = get().sessionActivityPhase?.get(sessionId);
if (phase === 'busy' || phase === 'cooldown') { if (phase === 'busy' || phase === 'cooldown') {
return; return;