diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index ff93e579..5bc617cd 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -25,8 +25,6 @@ export const ChatContainer: React.FC = () => { loadMessages, loadMoreMessages, updateViewportAnchor, - updateActiveTurnAnchor, - getActiveTurnAnchor, sessionMemoryState, openNewSessionDraft, isSyncing, @@ -75,26 +73,22 @@ export const ChatContainer: React.FC = () => { const { scrollRef, handleMessageContentChange, - getAnimationHandlers, - showScrollButton, - scrollToBottom, - spacerHeight, - pendingAnchorId, - hasActiveAnchor, + getAnimationHandlers, + showScrollButton, + scrollToBottom, + scrollToPosition, + isPinned, } = useChatScrollManager({ currentSessionId, sessionMessages, streamingMessageId, sessionMemoryState, updateViewportAnchor, - updateActiveTurnAnchor, - getActiveTurnAnchor, isSyncing, isMobile, messageStreamStates, sessionPermissions: sessionBlockingCards, trimToViewportWindow, - sessionActivityPhase, }); const memoryState = React.useMemo(() => { @@ -123,12 +117,12 @@ export const ChatContainer: React.FC = () => { await loadMoreMessages(currentSessionId, 'up'); if (container && prevHeight !== null && prevTop !== null) { const heightDiff = container.scrollHeight - prevHeight; - container.scrollTop = prevTop + heightDiff; + scrollToPosition(prevTop + heightDiff, { instant: true }); } } finally { setIsLoadingOlder(false); } - }, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef]); + }, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]); // Scroll to a specific message by ID (for timeline dialog) const scrollToMessage = React.useCallback((messageId: string) => { @@ -169,7 +163,8 @@ export const ChatContainer: React.FC = () => { } finally { const currentPhase = sessionActivityPhase?.get(currentSessionId) ?? 'idle'; 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 (typeof window === 'undefined') { @@ -184,7 +179,7 @@ export const ChatContainer: React.FC = () => { }; void load(); - }, [currentSessionId, hasActiveAnchor, loadMessages, messages, scrollToBottom, sessionActivityPhase]); + }, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionActivityPhase]); if (!currentSessionId && !draftOpen) { return ( @@ -277,7 +272,6 @@ export const ChatContainer: React.FC = () => { }} data-scroll-shadow="true" data-scrollbar="chat" - hideBottomShadow={!!pendingAnchorId} >
{ isLoadingOlder={isLoadingOlder} onLoadOlder={handleLoadOlder} scrollToBottom={scrollToBottom} - pendingAnchorId={pendingAnchorId} /> - {} - {spacerHeight > 0 && hasActiveAnchor && ( - diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 2731d4b8..9d3b37ca 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -360,6 +360,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return; + // Re-pin and scroll to bottom when sending scrollToBottom?.({ instant: true, force: true }); if (!currentProviderId || !currentModelId) { @@ -1356,25 +1357,28 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined} > - + {/* Absolute positioned above input - no layout shift */} +
+ +
void; animationHandlers?: AnimationHandlers; - scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void; - isPendingAnchor?: boolean; + scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; turnGroupingContext?: TurnGroupingContext; } @@ -74,7 +73,6 @@ const ChatMessage: React.FC = ({ nextMessage, onContentChange, animationHandlers, - isPendingAnchor = false, turnGroupingContext, }) => { const { isMobile, hasTouchInput } = useDeviceInfo(); @@ -834,7 +832,6 @@ const ChatMessage: React.FC = ({ )} data-message-id={message.info.id} ref={messageContainerRef} - style={isPendingAnchor ? { visibility: 'hidden' } : undefined} >
{isUser ? ( diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index abf8dc2e..61b3ce82 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -19,8 +19,7 @@ interface MessageListProps { hasMoreAbove: boolean; isLoadingOlder: boolean; onLoadOlder: () => void; - scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void; - pendingAnchorId?: string | null; + scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; } const MessageList: React.FC = ({ @@ -33,7 +32,6 @@ const MessageList: React.FC = ({ isLoadingOlder, onLoadOlder, scrollToBottom, - pendingAnchorId, }) => { React.useEffect(() => { if (permissions.length === 0 && questions.length === 0) { @@ -101,7 +99,6 @@ const MessageList: React.FC = ({ onContentChange={onMessageContentChange} animationHandlers={getAnimationHandlers(message.info.id)} scrollToBottom={scrollToBottom} - isPendingAnchor={pendingAnchorId === message.info.id} turnGroupingContext={getContextForMessage(message.info.id)} /> ))} @@ -118,6 +115,9 @@ const MessageList: React.FC = ({ ))}
)} + + {/* Bottom spacer - always 10% of viewport height */} + ); }; diff --git a/packages/ui/src/components/chat/hooks/useTurnGrouping.ts b/packages/ui/src/components/chat/hooks/useTurnGrouping.ts index 1eebf877..8b0dda43 100644 --- a/packages/ui/src/components/chat/hooks/useTurnGrouping.ts +++ b/packages/ui/src/components/chat/hooks/useTurnGrouping.ts @@ -56,15 +56,12 @@ export interface TurnGroupingContext { isWorking: boolean; isGroupExpanded: boolean; - previewedPartIds: Set; toggleGroup: () => void; - markPartsPreviewed: (partIds: string[]) => void; } interface TurnUiState { isExpanded: boolean; - previewedPartIds: Set; } interface TurnActivityInfo { @@ -424,7 +421,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe (turnId: string): TurnUiState => { const existing = turnUiStates.get(turnId); if (existing) return existing; - return { isExpanded: defaultActivityExpanded, previewedPartIds: new Set() }; + return { isExpanded: defaultActivityExpanded }; }, [turnUiStates, defaultActivityExpanded] ); @@ -432,25 +429,8 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe const toggleGroup = React.useCallback((turnId: string) => { setTurnUiStates((prev) => { const next = new Map(prev); - const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set() }; - next.set(turnId, { ...current, 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() }; - const newPreviewed = new Set(state.previewedPartIds); - partIds.forEach((id) => { - if (id && id.trim().length > 0) { - newPreviewed.add(id); - } - }); - next.set(turnId, { ...state, previewedPartIds: newPreviewed }); + const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded }; + next.set(turnId, { isExpanded: !current.isExpanded }); return next; }); }, [defaultActivityExpanded]); @@ -506,12 +486,10 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe userMessageCreatedAt, isWorking: isTurnWorking, isGroupExpanded: uiState.isExpanded, - previewedPartIds: uiState.previewedPartIds, toggleGroup: () => toggleGroup(turn.turnId), - markPartsPreviewed: (partIds: string[]) => markPartsPreviewedInternal(turn.turnId, partIds), } satisfies TurnGroupingContext; }, - [getOrCreateTurnState, lastTurnId, markPartsPreviewedInternal, messageToTurn, sessionIsWorking, toggleGroup, turnActivityInfo] + [getOrCreateTurnState, lastTurnId, messageToTurn, sessionIsWorking, toggleGroup, turnActivityInfo] ); diff --git a/packages/ui/src/components/chat/message/FadeInOnReveal.tsx b/packages/ui/src/components/chat/message/FadeInOnReveal.tsx index 7087d83e..928a27f8 100644 --- a/packages/ui/src/components/chat/message/FadeInOnReveal.tsx +++ b/packages/ui/src/components/chat/message/FadeInOnReveal.tsx @@ -4,15 +4,16 @@ import { cn } from '@/lib/utils'; interface FadeInOnRevealProps { children: React.ReactNode; className?: string; + skipAnimation?: boolean; } const FADE_ANIMATION_ENABLED = true; -export const FadeInOnReveal: React.FC = ({ children, className }) => { - const [visible, setVisible] = React.useState(false); +export const FadeInOnReveal: React.FC = ({ children, className, skipAnimation }) => { + const [visible, setVisible] = React.useState(skipAnimation ?? false); React.useEffect(() => { - if (!FADE_ANIMATION_ENABLED) { + if (!FADE_ANIMATION_ENABLED || skipAnimation) { return; } @@ -35,9 +36,9 @@ export const FadeInOnReveal: React.FC = ({ children, classN window.cancelAnimationFrame(frame); } }; - }, []); + }, [skipAnimation]); - if (!FADE_ANIMATION_ENABLED) { + if (!FADE_ANIMATION_ENABLED || skipAnimation) { return <>{children}; } diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 9419c925..36974809 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1,12 +1,9 @@ import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; -import AssistantTextPart from './parts/AssistantTextPart'; import UserTextPart from './parts/UserTextPart'; -import ReasoningPart from './parts/ReasoningPart'; import ToolPart from './parts/ToolPart'; import ProgressiveGroup from './parts/ProgressiveGroup'; -import MigratingPart from './parts/MigratingPart'; import { MessageFilesDisplay } from '../FileAttachment'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types'; @@ -37,77 +34,6 @@ const formatTurnDuration = (durationMs: number): string => { return `${minutes}m ${seconds}s`; }; - -const useMigrationTimer = ( - turnGroupingContext: TurnGroupingContext | undefined, - previewablePartIds: Set -): { isAnimating: boolean } => { - const timerRef = React.useRef(null); - const animationTimerRef = React.useRef(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(['task']); const isActivityStandaloneTool = (toolName: unknown): boolean => { @@ -429,15 +355,6 @@ const AssistantMessageBody: React.FC> = ({ return toolParts.every((toolPart) => isToolFinalized(toolPart)); }, [toolParts, hasPendingTools, isToolFinalized]); - const assistantTextReady = React.useMemo(() => { - if (assistantTextParts.length === 0) { - return true; - } - return assistantTextParts.every((part) => { - const time = (part as Record).time as Record | undefined; - return typeof time?.end === 'number'; - }); - }, [assistantTextParts]); const reasoningParts = React.useMemo(() => { return visibleParts.filter((part) => part.type === 'reasoning'); @@ -461,19 +378,7 @@ const AssistantMessageBody: React.FC> = ({ hasTools && (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 || (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized)); const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning; @@ -682,70 +587,6 @@ const AssistantMessageBody: React.FC> = ({ 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(); - previewableActivityPartsForMessage.forEach((activity) => { - ids.add(activity.id); - }); - return ids; - }, [previewableActivityPartsForMessage]); - - const { isAnimating: isMessageAnimating } = useMigrationTimer(turnGroupingContext, previewableActivityPartIds); - const shouldRenderActivityGroup = Boolean( turnGroupingContext && shouldShowActivityGroup && @@ -789,8 +630,6 @@ const AssistantMessageBody: React.FC> = ({ onToggleTool={onToggleTool} onShowPopup={onShowPopup} onContentChange={onContentChange} - isWorking={turnGroupingContext.isWorking} - previewedPartIds={turnGroupingContext.previewedPartIds} diffStats={turnGroupingContext.diffStats} /> ); @@ -885,146 +724,6 @@ const AssistantMessageBody: React.FC> = ({ 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 = ( - - - - ); - - element = wrapForMigration ? ( - - {toolElement} - - ) : 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 = ( - - - - ); - - element = wrapForMigration ? ( - - {reasoningElement} - - ) : 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 = ( - - - - ); - - element = wrapForMigration ? ( - - {textElement} - - ) : 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> = ({ activityPartsByPart, activityGroupSegmentsForMessage, expandedTools, - isMessageAnimating, isMobile, isToolFinalized, - messageId, onContentChange, onShowPopup, onToggleTool, - previewableActivityPartIds, - shouldHoldAssistantText, - shouldHoldReasoning, shouldHoldTools, shouldShowActivityGroup, showReasoningTraces, diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index d84b6114..9b7c0709 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -10,6 +10,8 @@ import ReasoningPart from './ReasoningPart'; import JustificationBlock from './JustificationBlock'; import { FadeInOnReveal } from '../FadeInOnReveal'; +const MAX_VISIBLE_COLLAPSED = 6; + interface DiffStats { additions: number; deletions: number; @@ -26,32 +28,9 @@ interface ProgressiveGroupProps { onToggleTool: (toolId: string) => void; onShowPopup: (content: ToolPopupContent) => void; onContentChange?: (reason?: ContentChangeReason) => void; - isWorking: boolean; - previewedPartIds: Set; 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[] => { return [...parts].sort((a, b) => { const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined; @@ -93,13 +72,12 @@ const ProgressiveGroup: React.FC = ({ expandedTools, onToggleTool, onContentChange, - isWorking, - previewedPartIds, diffStats, }) => { const previousExpandedRef = React.useRef(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); React.useEffect(() => { @@ -108,85 +86,101 @@ const ProgressiveGroup: React.FC = ({ previousExpandedRef.current = isExpanded; onContentChange?.('structural'); - // Increment key when expanding to trigger fresh animations if (isExpanded && wasCollapsed) { 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]); - 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); + // 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(); + 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) { 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 (
- {}
-
- {} -
- {} -
- -
- {} -
- {isExpanded ? ( - +
+
+ {isHeaderInteractive ? ( + <> +
+ +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+ ) : ( - + )}
+ Activity
- Activity -
- {(summary || diffStats) && ( -
- {summary && ( - {summary} - )} - {diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && ( + {diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && ( +
+{Math.max(0, diffStats.additions)} @@ -196,13 +190,10 @@ const ProgressiveGroup: React.FC = ({ -{Math.max(0, diffStats.deletions)} - )} -
- )} -
+
+ )} +
- {} - {isExpanded && (
= ({ 'before:top-[-0.25rem] before:bottom-0' )} > - {displayParts.map((activity, index) => { + {!isExpanded && hiddenCount > 0 && ( +
+ +{hiddenCount} more... +
+ )} + + {partsToRender.map((activity, index) => { const partId = activity.part.id || `group-part-${index}`; - const connection = toolConnections[partId]; + const connection = connectionsToUse[partId]; 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) { case 'tool': return ( - + = ({ case 'reasoning': return ( - + = ({ case 'justification': return ( - + = ({ } })}
- )}
); diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index 392512eb..65068dfa 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -42,8 +42,6 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) = isZombie: memoryState?.isZombie || false, backgroundCount: memoryState?.backgroundMessageCount || 0, lastAccessed: memoryState?.lastAccessedAt || 0, - activeTurnAnchorId: memoryState?.activeTurnAnchorId ?? null, - activeTurnSpacerHeight: memoryState?.activeTurnSpacerHeight ?? 0, isCurrent: session.id === currentSessionId }; }).sort((a, b) => b.lastAccessed - a.lastAccessed); @@ -145,11 +143,6 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) = }`}> {stat.messageCount} msgs - {stat.activeTurnAnchorId && stat.activeTurnSpacerHeight > 0 && ( - - anchor+{Math.round(stat.activeTurnSpacerHeight)}px - - )} {stat.backgroundCount > 0 && ( +{stat.backgroundCount} )} diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index 06c7cb81..09c045e7 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -1,5 +1,4 @@ import React from 'react'; -import { flushSync } from 'react-dom'; import type { Part } from '@opencode-ai/sdk/v2'; import { MessageFreshnessDetector } from '@/lib/messageFreshness'; @@ -26,7 +25,6 @@ interface SessionMemoryState { isZombie?: boolean; } -type SessionActivityPhase = 'idle' | 'busy' | 'cooldown'; interface UseChatScrollManagerOptions { currentSessionId: string | null; @@ -35,13 +33,10 @@ interface UseChatScrollManagerOptions { streamingMessageId: string | null; sessionMemoryState: Map; 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; isMobile: boolean; messageStreamStates: Map; trimToViewportWindow: (sessionId: string, targetSize?: number) => void; - sessionActivityPhase?: Map; } export interface AnimationHandlers { @@ -59,128 +54,76 @@ interface UseChatScrollManagerResult { handleMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; showScrollButton: boolean; - scrollToBottom: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void; - spacerHeight: number; - pendingAnchorId: string | null; - hasActiveAnchor: boolean; + scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void; + scrollToPosition: (position: number, options?: { instant?: boolean }) => void; + isPinned: 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; -// 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 ANCHOR_CLEAR_TOLERANCE_PX = 24; - -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; -}; +// Threshold for re-pinning: 10% of container height (matches bottom spacer) +const PIN_THRESHOLD_RATIO = 0.10; export const useChatScrollManager = ({ currentSessionId, sessionMessages, - streamingMessageId, updateViewportAnchor, - updateActiveTurnAnchor, - getActiveTurnAnchor, isSyncing, isMobile, - sessionActivityPhase, }: UseChatScrollManagerOptions): UseChatScrollManagerResult => { const scrollRef = React.useRef(null); const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile }); - const [anchorId, setAnchorId] = React.useState(null); - const [spacerHeight, setSpacerHeight] = React.useState(0); - const [showScrollButton, setShowScrollButton] = React.useState(false); - const [pendingAnchorId, setPendingAnchorId] = React.useState(null); + const getPinThreshold = React.useCallback(() => { + const container = scrollRef.current; + if (!container || container.clientHeight <= 0) { + 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(null); const lastSessionIdRef = React.useRef(null); const currentSessionIdRef = React.useRef(currentSessionId ?? null); const suppressUserScrollUntilRef = React.useRef(0); - const anchorClearIgnoreUntilRef = React.useRef(0); const lastDirectScrollIntentAtRef = React.useRef(0); - const previousMessageIdsRef = React.useRef>(new Set()); - const lastMessageCountRef = React.useRef(sessionMessages.length); - const spacerHeightRef = React.useRef(0); + const isPinnedRef = React.useRef(true); + const lastScrollTopRef = React.useRef(0); - const anchorIdRef = React.useRef(null); - const pendingRestoreAnchorRef = React.useRef<{ sessionId: string; anchorId: string; startedAt: number } | null>(null); - - const userScrollOverrideRef = React.useRef(false); - - const currentPhase = currentSessionId - ? sessionActivityPhase?.get(currentSessionId) ?? 'idle' - : 'idle'; - const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown'; React.useEffect(() => { currentSessionIdRef.current = currentSessionId ?? null; }, [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(() => { 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 container = scrollRef.current; if (!container) { @@ -188,126 +131,35 @@ export const useChatScrollManager = ({ return; } - if (pendingAnchorId) { - setShowScrollButton(false); - return; - } - const hasScrollableContent = container.scrollHeight > container.clientHeight; if (!hasScrollableContent) { setShowScrollButton(false); return; } - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - const currentSpacerHeight = spacerHeightRef.current; + // Show scroll button when scrolled above the 10vh threshold + const distanceFromBottom = getDistanceFromBottom(); + setShowScrollButton(distanceFromBottom > getPinThreshold()); + }, [getDistanceFromBottom, getPinThreshold]); - if (currentSpacerHeight > 0) { - 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 scrollToPosition = React.useCallback((position: number, options?: { instant?: boolean }) => { const container = scrollRef.current; 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(); - scrollEngine.scrollToPosition(Math.max(0, bottom), options); - }, [clearActiveTurnAnchor, currentPhase, currentSessionId, isSyncing, markProgrammaticScroll, scrollEngine]); + scrollEngine.scrollToPosition(Math.max(0, position), options); + }, [markProgrammaticScroll, scrollEngine]); - const scrollToNewAnchor = React.useCallback((messageId: string) => { - if (lastScrolledAnchorIdRef.current === messageId) { - return; - } - lastScrolledAnchorIdRef.current = messageId; + const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean }) => { + const container = scrollRef.current; + if (!container) return; - // Give the UI a grace window so incidental scroll/layout events don't clear the anchor. - anchorClearIgnoreUntilRef.current = Date.now() + ANCHOR_CLEAR_GRACE_MS; + // Re-pin when explicitly scrolling to bottom + updatePinnedState(true); - setPendingAnchorId(messageId); - const expectedSessionId = currentSessionIdRef.current; - - window.requestAnimationFrame(() => { - if (expectedSessionId !== currentSessionIdRef.current) { - return; - } - - const container = scrollRef.current; - if (!container) { - setPendingAnchorId(null); - return; - } - - const anchorElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null; - if (!anchorElement) { - setPendingAnchorId(null); - return; - } - - const containerHeight = container.clientHeight; - const targetScrollTop = calculateAnchorPosition(anchorElement); - - 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]); + scrollToBottomInternal(options); + setShowScrollButton(false); + }, [scrollToBottomInternal, updatePinnedState]); const handleScrollEvent = React.useCallback((event?: Event) => { const container = scrollRef.current; @@ -316,46 +168,44 @@ export const useChatScrollManager = ({ } 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; - if (event?.isTrusted && !isProgrammatic && hasDirectIntent) { - userScrollOverrideRef.current = true; - } - scrollEngine.handleScroll(); updateScrollButtonVisibility(); - const shouldIgnoreAnchorClear = now < anchorClearIgnoreUntilRef.current; + // Handle pin/unpin logic + const currentScrollTop = container.scrollTop; - if ( - event?.isTrusted && - !isProgrammatic && - !shouldIgnoreAnchorClear && - hasDirectIntent && - 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); + // Unpin requires strict user intent check + if (event?.isTrusted && !isProgrammatic && hasDirectIntent) { + const scrollingUp = currentScrollTop < lastScrollTopRef.current; + if (scrollingUp && isPinnedRef.current) { + updatePinnedState(false); + } } + // 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 position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1); const estimatedIndex = Math.floor(position * sessionMessages.length); updateViewportAnchor(currentSessionId, estimatedIndex); }, [ - clearActiveTurnAnchor, - currentPhase, currentSessionId, - isAnchorStillPinned, - pendingAnchorId, + getDistanceFromBottom, + getPinThreshold, scrollEngine, sessionMessages.length, + updatePinnedState, updateScrollButtonVisibility, updateViewportAnchor, ]); @@ -379,6 +229,7 @@ export const useChatScrollManager = ({ }; }, [handleScrollEvent]); + // Session switch - always start pinned at bottom useIsomorphicLayoutEffect(() => { if (!currentSessionId || currentSessionId === lastSessionIdRef.current) { return; @@ -387,175 +238,69 @@ export const useChatScrollManager = ({ lastSessionIdRef.current = currentSessionId; MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId); - previousMessageIdsRef.current = new Set( - sessionMessages.map(getMessageId).filter((id): id is string => Boolean(id)) - ); - 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); + // Always start pinned at bottom on session switch + updatePinnedState(true); setShowScrollButton(false); - userScrollOverrideRef.current = false; - }, [ - currentSessionId, - getActiveTurnAnchor, - isActivePhase, - markProgrammaticScroll, - scrollEngine, - updateActiveTurnAnchor, - updateSpacerHeight, - sessionMessages, - ]); - useIsomorphicLayoutEffect(() => { - if (typeof window === 'undefined') return; - if (!currentSessionId) return; + const container = scrollRef.current; + if (container) { + markProgrammaticScroll(); + scrollToBottomInternal({ instant: true }); + } + }, [currentSessionId, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]); - const pending = pendingRestoreAnchorRef.current; - if (!pending || pending.sessionId !== currentSessionId) return; + // Maintain pin-to-bottom when content changes + React.useEffect(() => { + if (!isPinnedRef.current) return; + if (isSyncing) return; const container = scrollRef.current; if (!container) return; - const anchorElement = container.querySelector(`[data-message-id="${pending.anchorId}"]`) as HTMLElement | null; - if (!anchorElement) { - // When the anchor is created from a just-sent user message, the persisted anchor can - // 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; + // When pinned and content grows, scroll to bottom instantly + const distanceFromBottom = getDistanceFromBottom(); + if (distanceFromBottom > getPinThreshold()) { + markProgrammaticScroll(); + scrollToBottomInternal({ instant: true }); } + }, [getDistanceFromBottom, getPinThreshold, isSyncing, markProgrammaticScroll, scrollToBottomInternal, sessionMessages]); - const targetScrollTop = calculateAnchorPosition(anchorElement); - markProgrammaticScroll(); - scrollEngine.scrollToPosition(targetScrollTop, { instant: true }); - pendingRestoreAnchorRef.current = null; - }, [calculateAnchorPosition, clearActiveTurnAnchor, currentSessionId, markProgrammaticScroll, scrollEngine, sessionMessages]); - - useIsomorphicLayoutEffect(() => { - if (isSyncing) { - return; - } - - if (lastSessionIdRef.current !== currentSessionId) { - 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]); - + // Use ResizeObserver to detect content changes and maintain pin React.useEffect(() => { const container = scrollRef.current; if (!container || typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(() => { 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); + // 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 () => { observer.disconnect(); + childObserver.disconnect(); }; - }, [updateScrollButtonVisibility]); + }, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]); React.useEffect(() => { if (typeof window === 'undefined') { @@ -572,23 +317,21 @@ export const useChatScrollManager = ({ }; }, [currentSessionId, sessionMessages.length, updateScrollButtonVisibility]); - React.useEffect(() => { - if (anchorId) { - updateScrollButtonVisibility(); - } - }, [anchorId, updateScrollButtonVisibility]); - - React.useEffect(() => { - updateScrollButtonVisibility(); - }, [spacerHeight, updateScrollButtonVisibility]); - const animationHandlersRef = React.useRef>(new Map()); const handleMessageContentChange = React.useCallback(() => { updateScrollButtonVisibility(); - }, [updateScrollButtonVisibility]); - const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => { + // 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 existing = animationHandlersRef.current.get(messageId); if (existing) { return existing; @@ -597,30 +340,34 @@ export const useChatScrollManager = ({ const handlers: AnimationHandlers = { onChunk: () => { updateScrollButtonVisibility(); + if (isPinnedRef.current) { + const distanceFromBottom = getDistanceFromBottom(); + if (distanceFromBottom > getPinThreshold()) { + scrollToBottomInternal({ instant: true }); + } + } }, onComplete: () => { updateScrollButtonVisibility(); }, - onStreamingCandidate: () => { - - }, - onAnimationStart: () => { - - }, + onStreamingCandidate: () => {}, + onAnimationStart: () => {}, onAnimatedHeightChange: () => { updateScrollButtonVisibility(); + if (isPinnedRef.current) { + const distanceFromBottom = getDistanceFromBottom(); + if (distanceFromBottom > getPinThreshold()) { + scrollToBottomInternal({ instant: true }); + } + } }, - onReservationCancelled: () => { - - }, - onReasoningBlock: () => { - - }, + onReservationCancelled: () => {}, + onReasoningBlock: () => {}, }; animationHandlersRef.current.set(messageId, handlers); return handlers; - }, [updateScrollButtonVisibility]); + }, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]); return { scrollRef, @@ -628,8 +375,7 @@ export const useChatScrollManager = ({ getAnimationHandlers, showScrollButton, scrollToBottom, - spacerHeight, - pendingAnchorId, - hasActiveAnchor: anchorId !== null, + scrollToPosition, + isPinned, }; }; diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 5d4981d6..6fd5446d 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -2,6 +2,7 @@ import React from 'react'; import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client'; import { saveSessionCursor } from '@/lib/messageCursorPersistence'; import { useSessionStore } from '@/stores/useSessionStore'; +import { getActiveSessionWindow } from '@/stores/types/sessionTypes'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -276,7 +277,7 @@ export const useEventStream = () => { ); const resyncMessages = React.useCallback( - (sessionId: string, reason: string) => { + (sessionId: string, reason: string, limit?: number) => { if (!sessionId) { return Promise.resolve(); } @@ -287,7 +288,7 @@ export const useEventStream = () => { if (now - lastResyncAtRef.current < RESYNC_DEBOUNCE_MS) { return Promise.resolve(); } - const task = loadMessages(sessionId) + const task = loadMessages(sessionId, limit) .catch((error) => { console.warn(`[useEventStream] Failed to resync messages (${reason}):`, error); }) @@ -309,7 +310,7 @@ export const useEventStream = () => { try { await Promise.all([ loadSessions(), - currentSessionId ? resyncMessages(currentSessionId, reason) : Promise.resolve(), + currentSessionId ? resyncMessages(currentSessionId, reason, Infinity) : Promise.resolve(), ]); } catch (error) { console.warn('[useEventStream] Bootstrap failed:', reason, error); @@ -1451,7 +1452,7 @@ export const useEventStream = () => { const sessionId = currentSessionIdRef.current; if (sessionId) { setTimeout(() => { - resyncMessages(sessionId, 'sse_reconnected') + resyncMessages(sessionId, 'sse_reconnected', Infinity) .then(() => requestSessionMetadataRefresh(sessionId)) .catch((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...'); const sessionId = currentSessionIdRef.current; if (sessionId) { - resyncMessages(sessionId, 'visibility_restore').catch(() => {}); + resyncMessages(sessionId, 'visibility_restore', getActiveSessionWindow()).catch(() => {}); requestSessionMetadataRefresh(sessionId); } @@ -1644,7 +1645,7 @@ export const useEventStream = () => { const sessionId = currentSessionIdRef.current; if (sessionId) { requestSessionMetadataRefresh(sessionId); - resyncMessages(sessionId, 'window_focus') + resyncMessages(sessionId, 'window_focus', getActiveSessionWindow()) .then(() => console.info('[useEventStream] Messages refreshed on focus')) .catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err)); } diff --git a/packages/ui/src/hooks/useScrollEngine.ts b/packages/ui/src/hooks/useScrollEngine.ts index c58a06ce..4022992c 100644 --- a/packages/ui/src/hooks/useScrollEngine.ts +++ b/packages/ui/src/hooks/useScrollEngine.ts @@ -7,6 +7,7 @@ type ScrollEngineOptions = { type ScrollOptions = { instant?: boolean; + followBottom?: boolean; // Dynamically track bottom during animation }; type ScrollEngineResult = { @@ -33,6 +34,7 @@ export const useScrollEngine = ({ const animationStartRef = React.useRef(null); const animationFromRef = React.useRef(0); const animationTargetRef = React.useRef(0); + const followBottomRef = React.useRef(false); const cancelAnimation = React.useCallback(() => { if (animationFrameRef.current !== null && typeof window !== 'undefined') { @@ -41,6 +43,7 @@ export const useScrollEngine = ({ animationFrameRef.current = null; animationStartRef.current = null; + followBottomRef.current = false; }, []); const runAnimationFrame = React.useCallback( @@ -55,6 +58,11 @@ export const useScrollEngine = ({ 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 easedProgress = 1 - Math.pow(1 - progress, 3); const from = animationFromRef.current; @@ -86,6 +94,7 @@ export const useScrollEngine = ({ const target = Math.max(0, position); const preferInstant = options?.instant ?? false; + const followBottom = options?.followBottom ?? false; manualOverrideRef.current = false; @@ -102,6 +111,11 @@ export const useScrollEngine = ({ return; } + // If followBottom animation is already running, don't restart - let it continue + if (followBottom && followBottomRef.current && animationFrameRef.current !== null) { + return; + } + cancelAnimation(); const distance = Math.abs(target - container.scrollTop); @@ -120,6 +134,7 @@ export const useScrollEngine = ({ animationFromRef.current = container.scrollTop; animationTargetRef.current = target; animationStartRef.current = null; + followBottomRef.current = followBottom; animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame); }, [cancelAnimation, containerRef, runAnimationFrame, setIsAtTop] diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index 8239797c..4669e1e9 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -343,7 +343,7 @@ interface MessageState { } interface MessageActions { - loadMessages: (sessionId: string) => Promise; + loadMessages: (sessionId: string, limit?: number) => Promise; 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; abortCurrentOperation: (currentSessionId?: string) => Promise; _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; syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => 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; evictLeastRecentlyUsed: (currentSessionId?: string) => void; loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise; @@ -387,10 +385,12 @@ export const useMessageStore = create()( loadMessages: async (sessionId: string, limit?: number) => { 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 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)); // Filter out reverted messages first @@ -2201,34 +2201,6 @@ export const useMessageStore = create()( }); }, - 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) => { const effectiveTargetSize = targetSize ?? getMemoryLimits().VIEWPORT_MESSAGES; const state = get(); @@ -2502,8 +2474,6 @@ export const useMessageStore = create()( totalAvailableMessages: memory.totalAvailableMessages, hasMoreAbove: memory.hasMoreAbove, trimmedHeadMaxId: memory.trimmedHeadMaxId, - activeTurnAnchorId: memory.activeTurnAnchorId, - activeTurnSpacerHeight: memory.activeTurnSpacerHeight, }, ]), sessionAbortFlags: Array.from(state.sessionAbortFlags.entries()).map(([sessionId, record]) => [ diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 4e2f5644..f9b9878b 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -35,10 +35,6 @@ export interface SessionMemoryState { hasMoreAbove?: boolean; trimmedHeadMaxId?: string; 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 { @@ -163,7 +159,7 @@ export interface SessionStore { shareSession: (id: string) => Promise; unshareSession: (id: string) => Promise; setCurrentSession: (id: string | null) => void; - loadMessages: (sessionId: string) => Promise; + loadMessages: (sessionId: string, limit?: number) => Promise; sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise; abortCurrentOperation: () => Promise; acknowledgeSessionAbort: (sessionId: string) => void; @@ -197,8 +193,6 @@ export interface SessionStore { clearAttachedFiles: () => 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; evictLeastRecentlyUsed: () => void; loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise; diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index 6bea47d2..d719cb26 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -310,7 +310,7 @@ export const useSessionStore = create()( 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) => { const draft = get().newSessionDraft; const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined; @@ -502,11 +502,9 @@ export const useSessionStore = create()( clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(), 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) => { 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); if (phase === 'busy' || phase === 'cooldown') { return;