refactor: redesign chat scroll system
The previous system layered three hooks (useScrollEngine, useChatScrollManager, useChatTimelineController) with overlapping responsibilities, four parallel ResizeObservers/MutationObservers, and six entry points to "scroll to bottom" (force-flag combinations, persistent follow loops, materialization recovery). This produced bugs where users could not break free of auto-follow during streaming: scrollbar drag, keyboard scrolling and find-in-page were not detected as user intent, and observers kept restarting the follow loop on every DOM mutation. The new architecture replaces the two low-level hooks with a single useChatAutoFollow that owns scroll behaviour end to end: - One state: 'following' or 'released'. No follow modes, no pin flags, no marker pixels. - One scroll writer: a lerp loop that runs only while the session is streaming and state is 'following'. Idle sessions never write scrollTop programmatically. - One user-intent detector: wheel up, touch drag down, keyboard (PageUp / Home / ArrowUp), pointerdown on the OverlayScrollbar thumb, and explicit releaseAutoFollow() calls all flip the state to 'released'. - A 1.2s grace period after explicit release: re-pin will not auto-engage inside this window, so a small wheel up cannot snap the user back even while they remain near the bottom spacer. - Re-pin and the scroll-to-bottom button share the same threshold: the height of the empty bottom spacer (10vh on desktop, 40px on mobile). Released users see the button only after they have scrolled past the spacer that already exists at the end of the chat. - Save/restore of scroll position uses ratio mapping, debounced at 150ms on user-driven scroll events; programmatic writes are masked via a short window so they never persist as user positions. - Container reattachment is detected via a useLayoutEffect probe over scrollRef.current. Listeners and observers re-bind when ChatViewport mounts after hydration or after the first message promotes a draft session into a real chat. - A pending-restore queue replays restoreSnapshot once the scroll container appears, fixing the case where a hydrating session landed at the top instead of the bottom. Removed: useScrollEngine.ts, useChatScrollManager.ts, the persistent follow loop with its own ResizeObserver+MutationObserver pair, the materialization-recovery .finally resume that yanked idle users to the bottom on transient sync gaps, and the openchamber:session-reselected event (re-select still works through the existing onSessionSelected callback). The openchamber:chat-force-scroll-bottom event remains for synthetic-message paths like git-message generation. Net change: ~1300 lines removed, two hooks replaced with one, one observer pair instead of four.
This commit is contained in:
@@ -12,7 +12,7 @@ import { QuestionCard } from './QuestionCard';
|
||||
import { StatusRowContainer } from './StatusRowContainer';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||
import { TimelineDialog } from './TimelineDialog';
|
||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
|
||||
// New sync system imports
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
import { useStreamingStore } from '@/sync/streaming';
|
||||
import {
|
||||
useSessionMessageCount,
|
||||
@@ -48,7 +47,6 @@ const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
||||
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected';
|
||||
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
|
||||
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
|
||||
const CHAT_SCROLL_STYLE = {
|
||||
@@ -153,7 +151,7 @@ type ChatViewportProps = {
|
||||
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
handleLoadOlder: () => void;
|
||||
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom: () => void;
|
||||
sessionQuestions: QuestionRequest[];
|
||||
sessionPermissions: PermissionRequest[];
|
||||
isProgrammaticFollowActive: boolean;
|
||||
@@ -328,9 +326,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
|
||||
const isSyncing = useViewportStore((s) => s.isSyncing);
|
||||
const sessionMemoryStateMap = useViewportStore((s) => s.sessionMemoryState);
|
||||
|
||||
// Sync actions
|
||||
const sync = useSync();
|
||||
@@ -347,7 +342,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
// UI store
|
||||
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
||||
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
|
||||
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
|
||||
|
||||
@@ -538,10 +532,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
}
|
||||
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
|
||||
|
||||
const sessionBlockingCards = React.useMemo(() => {
|
||||
return [...sessionPermissions, ...sessionQuestions];
|
||||
}, [sessionPermissions, sessionQuestions]);
|
||||
|
||||
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
|
||||
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
||||
activeTurnChangeRef.current(turnId);
|
||||
@@ -549,24 +539,19 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
const {
|
||||
scrollRef,
|
||||
handleMessageContentChange,
|
||||
notifyContentChange: handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
prepareForBottomResume,
|
||||
scrollToBottom,
|
||||
goToBottom,
|
||||
releaseAutoFollow,
|
||||
restoreSnapshot,
|
||||
isPinned,
|
||||
isOverflowing,
|
||||
isProgrammaticFollowActive,
|
||||
clearRestoreInProgress,
|
||||
} = useChatScrollManager({
|
||||
isFollowingProgrammatically,
|
||||
showScrollButton,
|
||||
} = useChatAutoFollow({
|
||||
currentSessionId,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
sessionMemoryState: sessionMemoryStateMap,
|
||||
updateViewportAnchor,
|
||||
isSyncing,
|
||||
isMobile,
|
||||
chatRenderMode,
|
||||
sessionPermissions: sessionBlockingCards,
|
||||
onActiveTurnChange: handleActiveTurnChange,
|
||||
});
|
||||
|
||||
@@ -579,47 +564,16 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
scrollRef,
|
||||
messageListRef,
|
||||
loadMoreMessages,
|
||||
prepareForBottomResume,
|
||||
scrollToBottom,
|
||||
goToBottom,
|
||||
releaseAutoFollow,
|
||||
isPinned,
|
||||
isOverflowing,
|
||||
showScrollButton,
|
||||
});
|
||||
const { loadEarlier, resumeToBottomInstant, restoreSavedScrollPosition } = timelineController;
|
||||
const { loadEarlier } = timelineController;
|
||||
|
||||
const runLatestInstantResume = React.useCallback(async (options?: { force?: boolean }) => {
|
||||
if (!currentSessionId) {
|
||||
scrollToBottom({ instant: true, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.force) {
|
||||
await resumeToBottomInstant();
|
||||
clearRestoreInProgress(currentSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this session has a saved non-bottom scroll position.
|
||||
const savedMemState = sessionMemoryStateMap.get(currentSessionId);
|
||||
const savedPos = savedMemState?.scrollPosition;
|
||||
if (savedPos && !sessionIsWorking) {
|
||||
const savedMaxScroll = Math.max(0, savedPos.scrollHeight - savedPos.clientHeight);
|
||||
// Use the same pixel threshold as the pin logic: 10% of clientHeight, clamped.
|
||||
const threshold = Math.max(24, Math.min(200, savedPos.clientHeight * 0.10));
|
||||
const distanceFromSavedBottom = savedMaxScroll - savedPos.scrollTop;
|
||||
if (savedMaxScroll > 0 && distanceFromSavedBottom > threshold) {
|
||||
await restoreSavedScrollPosition(savedPos);
|
||||
clearRestoreInProgress(currentSessionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await resumeToBottomInstant();
|
||||
clearRestoreInProgress(currentSessionId);
|
||||
}, [clearRestoreInProgress, currentSessionId, restoreSavedScrollPosition, resumeToBottomInstant, scrollToBottom, sessionIsWorking, sessionMemoryStateMap]);
|
||||
|
||||
const resumeToLatestInstant = React.useCallback((options?: { force?: boolean }) => {
|
||||
void runLatestInstantResume(options);
|
||||
}, [runLatestInstantResume]);
|
||||
const resumeToLatestInstant = React.useCallback(() => {
|
||||
goToBottom('instant');
|
||||
}, [goToBottom]);
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||
@@ -645,6 +599,21 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
resumeToBottom: timelineController.resumeToBottomInstant,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !currentSessionId) return;
|
||||
|
||||
const handleForceScrollBottom = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ sessionId?: string }>;
|
||||
if (customEvent.detail?.sessionId && customEvent.detail.sessionId !== currentSessionId) return;
|
||||
goToBottom('instant');
|
||||
};
|
||||
|
||||
window.addEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
|
||||
};
|
||||
}, [currentSessionId, goToBottom]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !currentSessionId || isDesktopExpandedInput) {
|
||||
return;
|
||||
@@ -688,29 +657,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
};
|
||||
}, [currentSessionId, isDesktopExpandedInput, navigation, scrollRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !currentSessionId) return;
|
||||
|
||||
const handleForceScrollBottom = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ sessionId?: string }>;
|
||||
if (customEvent.detail?.sessionId && customEvent.detail.sessionId !== currentSessionId) return;
|
||||
resumeToLatestInstant({ force: true });
|
||||
};
|
||||
|
||||
const handleSessionReselected = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<string>;
|
||||
if (customEvent.detail !== currentSessionId) return;
|
||||
if (isPinned || !isOverflowing || isProgrammaticFollowActive) return;
|
||||
void resumeToBottomInstant();
|
||||
};
|
||||
|
||||
window.addEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
|
||||
window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
|
||||
window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
|
||||
};
|
||||
}, [currentSessionId, isOverflowing, isPinned, isProgrammaticFollowActive, resumeToBottomInstant, resumeToLatestInstant]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
@@ -755,60 +701,32 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
&& !hasRenderableSessionSnapshot;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastScrolledSessionRef.current === currentSessionId) {
|
||||
return;
|
||||
}
|
||||
if (!currentSessionId) return;
|
||||
if (lastScrolledSessionRef.current === currentSessionId) return;
|
||||
|
||||
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||
if (hasHashTarget) {
|
||||
lastScrolledSessionRef.current = currentSessionId;
|
||||
clearRestoreInProgress(currentSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
lastScrolledSessionRef.current = currentSessionId;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
resumeToLatestInstant();
|
||||
if (hasHashTarget) {
|
||||
// Hash navigation handler will scroll to target; we just release auto-follow.
|
||||
releaseAutoFollow();
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
resumeToLatestInstant();
|
||||
});
|
||||
}, [clearRestoreInProgress, currentSessionId, resumeToLatestInstant]);
|
||||
const run = () => {
|
||||
void restoreSnapshot();
|
||||
};
|
||||
if (typeof window === 'undefined') {
|
||||
run();
|
||||
} else {
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
}, [currentSessionId, releaseAutoFollow, restoreSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) return;
|
||||
if (hasRenderableSessionSnapshot) return;
|
||||
|
||||
const load = async () => {
|
||||
await ensureSessionRenderable(currentSessionId).finally(() => {
|
||||
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
||||
const isActivePhase = statusType === 'busy' || statusType === 'retry';
|
||||
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||
// Active sessions are already followed by the scroll manager when pinned.
|
||||
// If the user scrolled away, a materialization retry must not force-resume.
|
||||
const shouldSkipScroll = hasHashTarget || isActivePhase;
|
||||
|
||||
if (!shouldSkipScroll) {
|
||||
if (typeof window === 'undefined') {
|
||||
resumeToLatestInstant();
|
||||
} else {
|
||||
window.requestAnimationFrame(() => {
|
||||
resumeToLatestInstant();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
void load();
|
||||
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, resumeToLatestInstant, sessionStatusForCurrent.type]);
|
||||
void ensureSessionRenderable(currentSessionId);
|
||||
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]);
|
||||
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
return (
|
||||
@@ -955,10 +873,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
handleMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
handleLoadOlder={handleLoadOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
scrollToBottom={resumeToLatestInstant}
|
||||
sessionQuestions={sessionQuestions}
|
||||
sessionPermissions={sessionPermissions}
|
||||
isProgrammaticFollowActive={isProgrammaticFollowActive}
|
||||
isProgrammaticFollowActive={isFollowingProgrammatically}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -634,7 +634,7 @@ const appendInlineText = (base: string, next: string): string => {
|
||||
|
||||
interface ChatInputProps {
|
||||
onOpenSettings?: () => void;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom?: () => void;
|
||||
}
|
||||
|
||||
type AutocompleteOverlayPosition = {
|
||||
@@ -1489,12 +1489,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
if (commandName === 'undo' && currentSessionId) {
|
||||
await useSessionUIStore.getState().handleSlashUndo(currentSessionId);
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
scrollToBottom?.();
|
||||
return;
|
||||
}
|
||||
else if (commandName === 'redo' && currentSessionId) {
|
||||
await useSessionUIStore.getState().handleSlashRedo(currentSessionId);
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
scrollToBottom?.();
|
||||
return;
|
||||
}
|
||||
else if (commandName === 'timeline' && currentSessionId) {
|
||||
@@ -1540,7 +1540,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
currentVariant,
|
||||
inputMode,
|
||||
);
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.summaryFailed'));
|
||||
}
|
||||
@@ -1562,7 +1562,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
currentVariant,
|
||||
inputMode,
|
||||
);
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.reviewFailed'));
|
||||
}
|
||||
@@ -1603,10 +1603,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
scrollToBottom?.();
|
||||
} else {
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
scrollToBottom?.();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import MessageHeader from './message/MessageHeader';
|
||||
import MessageBody from './message/MessageBody';
|
||||
import type { AgentMentionInfo } from './message/types';
|
||||
@@ -127,7 +127,7 @@ interface ChatMessageProps {
|
||||
};
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
animationHandlers?: AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom?: () => void;
|
||||
turnGroupingContext?: TurnGroupingContext;
|
||||
assistantHeaderMessageId?: string;
|
||||
isInActiveTurn?: boolean;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { measureElement as measureVirtualElement, type VirtualItem, useVirtualiz
|
||||
import ChatMessage from './ChatMessage';
|
||||
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
||||
import TurnItem from './components/TurnItem';
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
||||
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
|
||||
import { useTurnRecords } from './hooks/useTurnRecords';
|
||||
@@ -405,7 +405,7 @@ interface MessageListProps {
|
||||
hasMoreAbove: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
onLoadOlder: () => void;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom?: () => void;
|
||||
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
@@ -442,7 +442,7 @@ interface MessageRowProps {
|
||||
onUserAnimationConsumed?: (messageId: string) => void;
|
||||
onContentChange: (reason?: ContentChangeReason) => void;
|
||||
animationHandlers: AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom?: () => void;
|
||||
}
|
||||
|
||||
const MessageRow = React.memo<MessageRowProps>(({
|
||||
@@ -511,7 +511,7 @@ interface TurnBlockProps {
|
||||
chatRenderMode: 'sorted' | 'live';
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom?: () => void;
|
||||
stickyUserHeader?: boolean;
|
||||
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
||||
onUserAnimationConsumed: (messageId: string) => void;
|
||||
@@ -806,7 +806,7 @@ interface UngroupedMessageRowProps {
|
||||
nextMessage?: ChatMessageEntry;
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom?: () => void;
|
||||
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
||||
onUserAnimationConsumed: (messageId: string) => void;
|
||||
activeStreamingMessageId?: string | null;
|
||||
@@ -847,7 +847,7 @@ interface MessageListEntryProps {
|
||||
entry: RenderEntry;
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom?: () => void;
|
||||
stickyUserHeader?: boolean;
|
||||
sessionIsWorking: boolean;
|
||||
defaultActivityExpanded: boolean;
|
||||
@@ -938,7 +938,7 @@ const StaticHistoryList: React.FC<{
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom?: () => void;
|
||||
stickyUserHeader: boolean;
|
||||
defaultActivityExpanded: boolean;
|
||||
turnUiStates: Map<string, TurnUiState>;
|
||||
@@ -1023,7 +1023,7 @@ const StreamingTailContent: React.FC<{
|
||||
entry: RenderEntry;
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom?: () => void;
|
||||
stickyUserHeader: boolean;
|
||||
sessionIsWorking: boolean;
|
||||
defaultActivityExpanded: boolean;
|
||||
@@ -1103,8 +1103,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
|
||||
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
|
||||
const stableOnLoadOlder = useStableEvent(onLoadOlder);
|
||||
const stableScrollToBottom = useStableEvent((options?: { instant?: boolean; force?: boolean }) => {
|
||||
scrollToBottom?.(options);
|
||||
const stableScrollToBottom = useStableEvent(() => {
|
||||
scrollToBottom?.();
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
|
||||
import type { TurnActivityRecord } from '../lib/turns/types';
|
||||
import type { ToolPopupContent } from '../message/types';
|
||||
import type { StreamPhase } from '../message/types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
|
||||
interface DiffStats {
|
||||
additions: number;
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from '../lib/turns/windowTurns';
|
||||
import type { TurnHistorySignals } from '../lib/turns/historySignals';
|
||||
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
|
||||
import { useViewportStore, type SessionMemoryState } from '@/sync/viewport-store';
|
||||
|
||||
type ViewportAnchor = { messageId: string; offsetTop: number };
|
||||
|
||||
@@ -33,10 +32,10 @@ interface UseChatTimelineControllerOptions {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
messageListRef: React.RefObject<MessageListHandle | null>;
|
||||
loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise<void>;
|
||||
prepareForBottomResume: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToBottom: (options?: { instant?: boolean; force?: boolean; followBottom?: boolean }) => void;
|
||||
goToBottom: (mode?: 'instant' | 'smooth') => void;
|
||||
releaseAutoFollow: () => void;
|
||||
isPinned: boolean;
|
||||
isOverflowing: boolean;
|
||||
showScrollButton: boolean;
|
||||
}
|
||||
|
||||
export interface UseChatTimelineControllerResult {
|
||||
@@ -52,8 +51,7 @@ export interface UseChatTimelineControllerResult {
|
||||
loadEarlier: () => Promise<void>;
|
||||
revealBufferedTurns: () => Promise<boolean>;
|
||||
resumeToBottom: () => void;
|
||||
resumeToBottomInstant: () => void;
|
||||
restoreSavedScrollPosition: (savedPos: NonNullable<SessionMemoryState['scrollPosition']>) => Promise<void>;
|
||||
resumeToBottomInstant: () => Promise<void>;
|
||||
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
||||
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
||||
captureViewportAnchor: () => ViewportAnchor | null;
|
||||
@@ -68,10 +66,10 @@ export const useChatTimelineController = ({
|
||||
scrollRef,
|
||||
messageListRef,
|
||||
loadMoreMessages,
|
||||
prepareForBottomResume,
|
||||
scrollToBottom,
|
||||
goToBottom,
|
||||
releaseAutoFollow,
|
||||
isPinned,
|
||||
isOverflowing,
|
||||
showScrollButton,
|
||||
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
|
||||
const previousTurnWindowModelRef = React.useRef<TurnWindowModel | null>(null);
|
||||
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
|
||||
@@ -376,6 +374,7 @@ export const useChatTimelineController = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
releaseAutoFollow();
|
||||
setPendingRevealWork(true);
|
||||
|
||||
try {
|
||||
@@ -412,7 +411,7 @@ export const useChatTimelineController = ({
|
||||
} finally {
|
||||
setPendingRevealWork(false);
|
||||
}
|
||||
}, [attemptPendingScrollRequest, sessionId]);
|
||||
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
|
||||
|
||||
const scrollToMessage = React.useCallback(async (
|
||||
messageId: string,
|
||||
@@ -422,6 +421,7 @@ export const useChatTimelineController = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
releaseAutoFollow();
|
||||
setPendingRevealWork(true);
|
||||
|
||||
try {
|
||||
@@ -460,13 +460,12 @@ export const useChatTimelineController = ({
|
||||
} finally {
|
||||
setPendingRevealWork(false);
|
||||
}
|
||||
}, [attemptPendingScrollRequest, sessionId]);
|
||||
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
|
||||
|
||||
const resumeToBottom = React.useCallback(async () => {
|
||||
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
||||
setPendingRevealWork(false);
|
||||
setIsLoadingOlder(false);
|
||||
prepareForBottomResume({ force: true });
|
||||
|
||||
const shouldWaitForRender = nextStart !== turnStartRef.current;
|
||||
if (shouldWaitForRender) {
|
||||
@@ -474,14 +473,13 @@ export const useChatTimelineController = ({
|
||||
await waitForNextRenderCommit();
|
||||
}
|
||||
|
||||
scrollToBottom({ force: true });
|
||||
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
|
||||
goToBottom('smooth');
|
||||
}, [goToBottom, waitForNextRenderCommit]);
|
||||
|
||||
const resumeToBottomInstant = React.useCallback(async () => {
|
||||
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
||||
setPendingRevealWork(false);
|
||||
setIsLoadingOlder(false);
|
||||
prepareForBottomResume({ instant: true, force: true });
|
||||
|
||||
const shouldWaitForRender = nextStart !== turnStartRef.current;
|
||||
if (shouldWaitForRender) {
|
||||
@@ -489,47 +487,8 @@ export const useChatTimelineController = ({
|
||||
await waitForNextRenderCommit();
|
||||
}
|
||||
|
||||
scrollToBottom({ instant: true, force: true, followBottom: true });
|
||||
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
|
||||
|
||||
// Restore scroll position from a saved pixel snapshot using ratio mapping.
|
||||
// Separate from resumeToBottomInstant to preserve "always go to bottom" semantics.
|
||||
const restoreSavedScrollPosition = React.useCallback(async (savedPos: NonNullable<SessionMemoryState['scrollPosition']>) => {
|
||||
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
||||
setPendingRevealWork(false);
|
||||
setIsLoadingOlder(false);
|
||||
|
||||
const shouldWaitForRender = nextStart !== turnStartRef.current;
|
||||
if (shouldWaitForRender) {
|
||||
setTurnStart(nextStart);
|
||||
await waitForNextRenderCommit();
|
||||
}
|
||||
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const savedMaxScroll = Math.max(0, savedPos.scrollHeight - savedPos.clientHeight);
|
||||
if (savedMaxScroll <= 0) return;
|
||||
|
||||
const ratio = savedPos.scrollTop / savedMaxScroll;
|
||||
const currentMaxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const restoredTop = Math.round(ratio * currentMaxScroll);
|
||||
container.scrollTop = restoredTop;
|
||||
|
||||
// Re-persist the restored position so intermediate scroll events
|
||||
// during the transition don't leave stale data for the next switch.
|
||||
const sid = sessionIdRef.current;
|
||||
if (sid) {
|
||||
const memState = useViewportStore.getState().sessionMemoryState.get(sid);
|
||||
if (memState) {
|
||||
useViewportStore.getState().updateViewportAnchor(sid, memState.viewportAnchor, {
|
||||
scrollTop: restoredTop,
|
||||
scrollHeight: container.scrollHeight,
|
||||
clientHeight: container.clientHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [scrollRef, waitForNextRenderCommit]);
|
||||
goToBottom('instant');
|
||||
}, [goToBottom, waitForNextRenderCommit]);
|
||||
|
||||
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
||||
setActiveTurnId(turnId);
|
||||
@@ -543,13 +502,12 @@ export const useChatTimelineController = ({
|
||||
isLoadingOlder,
|
||||
pendingRevealWork,
|
||||
activeTurnId,
|
||||
showScrollToBottom: isOverflowing && !isPinned && !pendingRevealWork,
|
||||
showScrollToBottom: showScrollButton && !pendingRevealWork,
|
||||
turnWindowModel,
|
||||
loadEarlier,
|
||||
revealBufferedTurns,
|
||||
resumeToBottom,
|
||||
resumeToBottomInstant,
|
||||
restoreSavedScrollPosition,
|
||||
scrollToTurn,
|
||||
scrollToMessage,
|
||||
captureViewportAnchor,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine, RiBookletLine, RiGlobalLine, RiInformationLine } from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
|
||||
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import type { StreamPhase } from '../types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ReasoningTimelineBlock } from './ReasoningPart';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
|
||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||
import type { StreamPhase } from '../types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ToolPopupContent } from '../types';
|
||||
import ToolPart from './ToolPart';
|
||||
import { MinDurationShineText } from './MinDurationShineText';
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ComponentType } from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDurationTickerNow } from './useDurationTicker';
|
||||
|
||||
@@ -21,7 +21,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ToolPopupContent } from '../types';
|
||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
|
||||
@@ -85,11 +85,6 @@ export const useSessionActions = (args: Args) => {
|
||||
|
||||
if (sessionId === args.currentSessionId) {
|
||||
if (args.allowReselect) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent<string>('openchamber:session-reselected', {
|
||||
detail: sessionId,
|
||||
}));
|
||||
}
|
||||
args.onSessionSelected?.(sessionId);
|
||||
}
|
||||
resetSessionSearch();
|
||||
|
||||
@@ -322,6 +322,7 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
||||
{showVertical && (
|
||||
<div
|
||||
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--vertical"
|
||||
data-overlay-scrollbar-thumb="vertical"
|
||||
style={{
|
||||
height: `${vertical.length}px`,
|
||||
top: `${trackInset + vertical.offset}px`,
|
||||
@@ -338,6 +339,7 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
||||
{showHorizontal && (
|
||||
<div
|
||||
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--horizontal"
|
||||
data-overlay-scrollbar-thumb="horizontal"
|
||||
style={{
|
||||
width: `${horizontal.length}px`,
|
||||
left: `${trackInset + horizontal.offset}px`,
|
||||
|
||||
Reference in New Issue
Block a user