fix: stabilize chat auto-scroll and bottom resume
- Unify send and session resumes around the latest chat tail - Keep smooth follow active during assistant message growth Remove staged chat rendering from the main scroll path
This commit is contained in:
@@ -15,7 +15,6 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
|||||||
import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||||
import { useTimelineStaging } from '@/hooks/useTimelineStaging';
|
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||||
@@ -311,6 +310,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
|
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
|
||||||
const [suspendDetachedTailUpdates, setSuspendDetachedTailUpdates] = React.useState(false);
|
const [suspendDetachedTailUpdates, setSuspendDetachedTailUpdates] = React.useState(false);
|
||||||
|
const [forceLiveViewport, setForceLiveViewport] = React.useState(false);
|
||||||
// Messages from sync system
|
// Messages from sync system
|
||||||
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', undefined, {
|
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', undefined, {
|
||||||
suspendPartUpdates: suspendDetachedTailUpdates,
|
suspendPartUpdates: suspendDetachedTailUpdates,
|
||||||
@@ -366,6 +366,10 @@ export const ChatContainer: React.FC = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (streamingMessageId || activeStreamingPhase) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
||||||
if (statusType === 'busy' || statusType === 'retry') {
|
if (statusType === 'busy' || statusType === 'retry') {
|
||||||
return true;
|
return true;
|
||||||
@@ -377,7 +381,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
&& lastMessage.role === 'assistant'
|
&& lastMessage.role === 'assistant'
|
||||||
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
|
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
|
||||||
);
|
);
|
||||||
}, [currentSessionId, sessionMessages, sessionPermissions.length, sessionStatusForCurrent.type]);
|
}, [activeStreamingPhase, currentSessionId, sessionMessages, sessionPermissions.length, sessionStatusForCurrent.type, streamingMessageId]);
|
||||||
const activeRetryStatus = React.useMemo(() => {
|
const activeRetryStatus = React.useMemo(() => {
|
||||||
if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
|
if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
|
||||||
return null;
|
return null;
|
||||||
@@ -487,6 +491,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
handleMessageContentChange,
|
handleMessageContentChange,
|
||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
|
prepareForBottomResume,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
isPinned,
|
isPinned,
|
||||||
isOverflowing,
|
isOverflowing,
|
||||||
@@ -505,9 +510,9 @@ export const ChatContainer: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const next = Boolean(currentSessionId && streamingMessageId && !isPinned);
|
const next = Boolean(currentSessionId && streamingMessageId && !isPinned && !forceLiveViewport);
|
||||||
setSuspendDetachedTailUpdates((previous) => (previous === next ? previous : next));
|
setSuspendDetachedTailUpdates((previous) => (previous === next ? previous : next));
|
||||||
}, [currentSessionId, isPinned, streamingMessageId]);
|
}, [currentSessionId, forceLiveViewport, isPinned, streamingMessageId]);
|
||||||
|
|
||||||
const viewportMessagesRef = React.useRef<SessionMessageRecord[]>(EMPTY_MESSAGES);
|
const viewportMessagesRef = React.useRef<SessionMessageRecord[]>(EMPTY_MESSAGES);
|
||||||
const viewportSessionIdRef = React.useRef<string | null>(null);
|
const viewportSessionIdRef = React.useRef<string | null>(null);
|
||||||
@@ -522,6 +527,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
currentSessionId
|
currentSessionId
|
||||||
&& streamingMessageId
|
&& streamingMessageId
|
||||||
&& !isPinned
|
&& !isPinned
|
||||||
|
&& !forceLiveViewport
|
||||||
&& historyMeta?.loading !== true
|
&& historyMeta?.loading !== true
|
||||||
&& canFreezeDetachedViewport(viewportMessagesRef.current, sessionMessages, streamingMessageId),
|
&& canFreezeDetachedViewport(viewportMessagesRef.current, sessionMessages, streamingMessageId),
|
||||||
);
|
);
|
||||||
@@ -532,28 +538,45 @@ export const ChatContainer: React.FC = () => {
|
|||||||
|
|
||||||
viewportMessagesRef.current = sessionMessages;
|
viewportMessagesRef.current = sessionMessages;
|
||||||
return sessionMessages;
|
return sessionMessages;
|
||||||
}, [currentSessionId, historyMeta?.loading, isPinned, sessionMessages, streamingMessageId]);
|
}, [currentSessionId, forceLiveViewport, historyMeta?.loading, isPinned, sessionMessages, streamingMessageId]);
|
||||||
|
|
||||||
// Deferred timeline staging — renders 1 message on first paint,
|
|
||||||
// adds 3 per rAF frame to avoid blocking.
|
|
||||||
const { stagedMessages } = useTimelineStaging({
|
|
||||||
sessionKey: currentSessionId ?? '',
|
|
||||||
messages: viewportMessages,
|
|
||||||
});
|
|
||||||
|
|
||||||
const timelineController = useChatTimelineController({
|
const timelineController = useChatTimelineController({
|
||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
messages: stagedMessages,
|
messages: viewportMessages,
|
||||||
historyMeta,
|
historyMeta,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
messageListRef,
|
messageListRef,
|
||||||
loadMoreMessages,
|
loadMoreMessages,
|
||||||
|
prepareForBottomResume,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
isPinned,
|
isPinned,
|
||||||
isOverflowing,
|
isOverflowing,
|
||||||
});
|
});
|
||||||
const { loadEarlier, resumeToBottomInstant } = timelineController;
|
const { loadEarlier, resumeToBottomInstant } = timelineController;
|
||||||
|
|
||||||
|
const runLatestInstantResume = React.useCallback(async () => {
|
||||||
|
setForceLiveViewport(true);
|
||||||
|
try {
|
||||||
|
if (!currentSessionId) {
|
||||||
|
scrollToBottom({ instant: true, force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await resumeToBottomInstant();
|
||||||
|
} finally {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
setForceLiveViewport(false);
|
||||||
|
} else {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setForceLiveViewport(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentSessionId, resumeToBottomInstant, scrollToBottom]);
|
||||||
|
|
||||||
|
const resumeToLatestInstant = React.useCallback(() => {
|
||||||
|
void runLatestInstantResume();
|
||||||
|
}, [runLatestInstantResume]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||||
}, [timelineController.handleActiveTurnChange]);
|
}, [timelineController.handleActiveTurnChange]);
|
||||||
@@ -575,7 +598,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
activeTurnId: timelineController.activeTurnId,
|
activeTurnId: timelineController.activeTurnId,
|
||||||
scrollToTurn: timelineController.scrollToTurn,
|
scrollToTurn: timelineController.scrollToTurn,
|
||||||
scrollToMessage: timelineController.scrollToMessage,
|
scrollToMessage: timelineController.scrollToMessage,
|
||||||
resumeToBottom: timelineController.resumeToBottom,
|
resumeToBottom: timelineController.resumeToBottomInstant,
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -585,7 +608,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
const customEvent = event as CustomEvent<string>;
|
const customEvent = event as CustomEvent<string>;
|
||||||
if (customEvent.detail !== currentSessionId) return;
|
if (customEvent.detail !== currentSessionId) return;
|
||||||
if (isPinned || !isOverflowing || isProgrammaticFollowActive) return;
|
if (isPinned || !isOverflowing || isProgrammaticFollowActive) return;
|
||||||
resumeToBottomInstant();
|
void resumeToBottomInstant();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
|
window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
|
||||||
@@ -632,11 +655,39 @@ export const ChatContainer: React.FC = () => {
|
|||||||
|
|
||||||
const hasHistoryMetadata = Boolean(historyMeta);
|
const hasHistoryMetadata = Boolean(historyMeta);
|
||||||
const lastHydratedSessionRef = React.useRef<string | null>(null);
|
const lastHydratedSessionRef = React.useRef<string | null>(null);
|
||||||
|
const lastScrolledSessionRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
const isSessionHydrating =
|
const isSessionHydrating =
|
||||||
Boolean(currentSessionId)
|
Boolean(currentSessionId)
|
||||||
&& (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true);
|
&& (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastScrolledSessionRef.current === currentSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||||
|
if (hasHashTarget) {
|
||||||
|
lastScrolledSessionRef.current = currentSessionId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrolledSessionRef.current = currentSessionId;
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
resumeToLatestInstant();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
resumeToLatestInstant();
|
||||||
|
});
|
||||||
|
}, [currentSessionId, resumeToLatestInstant]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId) return;
|
if (!currentSessionId) return;
|
||||||
if (hasSessionMessagesEntry && hasHistoryMetadata) return;
|
if (hasSessionMessagesEntry && hasHistoryMetadata) return;
|
||||||
@@ -653,10 +704,10 @@ export const ChatContainer: React.FC = () => {
|
|||||||
|
|
||||||
if (!shouldSkipScroll) {
|
if (!shouldSkipScroll) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
scrollToBottom({ instant: true });
|
resumeToLatestInstant();
|
||||||
} else {
|
} else {
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
scrollToBottom({ instant: true });
|
resumeToLatestInstant();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,7 +715,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
}, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
|
}, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, resumeToLatestInstant, sessionMessages.length, sessionStatusForCurrent.type]);
|
||||||
|
|
||||||
if (!currentSessionId && !draftOpen) {
|
if (!currentSessionId && !draftOpen) {
|
||||||
return (
|
return (
|
||||||
@@ -696,7 +747,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChatInput scrollToBottom={scrollToBottom} />
|
<ChatInput scrollToBottom={resumeToLatestInstant} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -759,7 +810,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChatInput scrollToBottom={scrollToBottom} />
|
<ChatInput scrollToBottom={resumeToLatestInstant} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -795,7 +846,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChatInput scrollToBottom={scrollToBottom} />
|
<ChatInput scrollToBottom={resumeToLatestInstant} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -846,7 +897,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
onClick={navigation.resumeToLatest}
|
onClick={navigation.resumeToLatest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ChatInput scrollToBottom={scrollToBottom} />
|
<ChatInput scrollToBottom={resumeToLatestInstant} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1305,9 +1305,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-pin and scroll to bottom when sending
|
|
||||||
scrollToBottom?.({ instant: true, force: true });
|
|
||||||
|
|
||||||
if (!currentProviderId || !currentModelId) {
|
if (!currentProviderId || !currentModelId) {
|
||||||
console.warn('Cannot send message: provider or model not selected');
|
console.warn('Cannot send message: provider or model not selected');
|
||||||
return;
|
return;
|
||||||
@@ -1483,7 +1480,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
...additionalParts.flatMap(p => p.attachments ?? []),
|
...additionalParts.flatMap(p => p.attachments ?? []),
|
||||||
];
|
];
|
||||||
|
|
||||||
void sendMessage(
|
const sendPromise = sendMessage(
|
||||||
primaryText,
|
primaryText,
|
||||||
currentProviderId,
|
currentProviderId,
|
||||||
currentModelId,
|
currentModelId,
|
||||||
@@ -1493,7 +1490,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
additionalParts.length > 0 ? additionalParts : undefined,
|
additionalParts.length > 0 ? additionalParts : undefined,
|
||||||
currentVariant,
|
currentVariant,
|
||||||
inputMode
|
inputMode
|
||||||
).then(() => {
|
);
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
scrollToBottom?.({ instant: true, force: true });
|
||||||
|
} else {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
scrollToBottom?.({ instant: true, force: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendPromise.then(() => {
|
||||||
// Clear linked issue after successful message send
|
// Clear linked issue after successful message send
|
||||||
if (linkedIssue) {
|
if (linkedIssue) {
|
||||||
setLinkedIssue(null);
|
setLinkedIssue(null);
|
||||||
|
|||||||
@@ -737,10 +737,10 @@ const buildMarkdownComponents = ({
|
|||||||
return <td {...props} className={cn('border-r border-border/60 px-4 py-2.5 align-middle text-foreground/90 last:border-r-0', props.className)}>{children}</td>;
|
return <td {...props} className={cn('border-r border-border/60 px-4 py-2.5 align-middle text-foreground/90 last:border-r-0', props.className)}>{children}</td>;
|
||||||
},
|
},
|
||||||
ul({ children, ...props }) {
|
ul({ children, ...props }) {
|
||||||
return <ul {...props} className={cn('typography-markdown-body my-2 pl-6', props.className)}>{children}</ul>;
|
return <ul {...props} className={cn('typography-markdown-body my-2', props.className)}>{children}</ul>;
|
||||||
},
|
},
|
||||||
ol({ children, ...props }) {
|
ol({ children, ...props }) {
|
||||||
return <ol {...props} className={cn('typography-markdown-body my-2 pl-6', props.className)}>{children}</ol>;
|
return <ol {...props} className={cn('typography-markdown-body my-2', props.className)}>{children}</ol>;
|
||||||
},
|
},
|
||||||
li({ children, ...props }) {
|
li({ children, ...props }) {
|
||||||
return <li {...props} className={cn('typography-markdown-body my-0.5 text-foreground/90', props.className)}>{children}</li>;
|
return <li {...props} className={cn('typography-markdown-body my-0.5 text-foreground/90', props.className)}>{children}</li>;
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ interface UseChatTimelineControllerOptions {
|
|||||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
messageListRef: React.RefObject<MessageListHandle | null>;
|
messageListRef: React.RefObject<MessageListHandle | null>;
|
||||||
loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise<void>;
|
loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise<void>;
|
||||||
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
|
prepareForBottomResume: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||||
|
scrollToBottom: (options?: { instant?: boolean; force?: boolean; followBottom?: boolean }) => void;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
isOverflowing: boolean;
|
isOverflowing: boolean;
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,7 @@ export const useChatTimelineController = ({
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
messageListRef,
|
messageListRef,
|
||||||
loadMoreMessages,
|
loadMoreMessages,
|
||||||
|
prepareForBottomResume,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
isPinned,
|
isPinned,
|
||||||
isOverflowing,
|
isOverflowing,
|
||||||
@@ -484,21 +486,35 @@ export const useChatTimelineController = ({
|
|||||||
}
|
}
|
||||||
}, [attemptPendingScrollRequest, sessionId]);
|
}, [attemptPendingScrollRequest, sessionId]);
|
||||||
|
|
||||||
const resumeToBottom = React.useCallback(() => {
|
const resumeToBottom = React.useCallback(async () => {
|
||||||
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
||||||
setTurnStart(nextStart);
|
|
||||||
setPendingRevealWork(false);
|
setPendingRevealWork(false);
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
scrollToBottom({ force: true });
|
prepareForBottomResume({ force: true });
|
||||||
}, [scrollToBottom]);
|
|
||||||
|
|
||||||
const resumeToBottomInstant = React.useCallback(() => {
|
const shouldWaitForRender = nextStart !== turnStartRef.current;
|
||||||
|
if (shouldWaitForRender) {
|
||||||
|
setTurnStart(nextStart);
|
||||||
|
await waitForNextRenderCommit();
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom({ force: true });
|
||||||
|
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
|
||||||
|
|
||||||
|
const resumeToBottomInstant = React.useCallback(async () => {
|
||||||
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
||||||
setTurnStart(nextStart);
|
|
||||||
setPendingRevealWork(false);
|
setPendingRevealWork(false);
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
scrollToBottom({ instant: true, force: true });
|
prepareForBottomResume({ instant: true, force: true });
|
||||||
}, [scrollToBottom]);
|
|
||||||
|
const shouldWaitForRender = nextStart !== turnStartRef.current;
|
||||||
|
if (shouldWaitForRender) {
|
||||||
|
setTurnStart(nextStart);
|
||||||
|
await waitForNextRenderCommit();
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom({ instant: true, force: true, followBottom: true });
|
||||||
|
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
|
||||||
|
|
||||||
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
||||||
setActiveTurnId(turnId);
|
setActiveTurnId(turnId);
|
||||||
|
|||||||
@@ -45,11 +45,19 @@ export interface AnimationHandlers {
|
|||||||
onAnimatedHeightChange?: (height: number) => void;
|
onAnimatedHeightChange?: (height: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FollowMode = 'none' | 'smooth';
|
||||||
|
|
||||||
|
type AutoScrollMarker = {
|
||||||
|
top: number;
|
||||||
|
at: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface UseChatScrollManagerResult {
|
interface UseChatScrollManagerResult {
|
||||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
showScrollButton: boolean;
|
showScrollButton: boolean;
|
||||||
|
prepareForBottomResume: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||||
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
|
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||||
scrollToPosition: (position: number, options?: { instant?: boolean }) => void;
|
scrollToPosition: (position: number, options?: { instant?: boolean }) => void;
|
||||||
releasePinnedScroll: () => void;
|
releasePinnedScroll: () => void;
|
||||||
@@ -59,7 +67,6 @@ interface UseChatScrollManagerResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
|
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
|
||||||
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
|
|
||||||
// Threshold for re-pinning: 10% of container height (matches bottom spacer)
|
// Threshold for re-pinning: 10% of container height (matches bottom spacer)
|
||||||
const PIN_THRESHOLD_RATIO = 0.10;
|
const PIN_THRESHOLD_RATIO = 0.10;
|
||||||
const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150;
|
const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150;
|
||||||
@@ -106,22 +113,40 @@ export const useChatScrollManager = ({
|
|||||||
const isOverflowingRef = React.useRef(false);
|
const isOverflowingRef = React.useRef(false);
|
||||||
|
|
||||||
const lastSessionIdRef = React.useRef<string | null>(null);
|
const lastSessionIdRef = React.useRef<string | null>(null);
|
||||||
const suppressUserScrollUntilRef = React.useRef<number>(0);
|
|
||||||
const lastDirectScrollIntentAtRef = React.useRef<number>(0);
|
|
||||||
const isPinnedRef = React.useRef(true);
|
const isPinnedRef = React.useRef(true);
|
||||||
const lastScrollTopRef = React.useRef<number>(0);
|
const lastScrollTopRef = React.useRef<number>(0);
|
||||||
const touchLastYRef = React.useRef<number | null>(null);
|
const touchLastYRef = React.useRef<number | null>(null);
|
||||||
const pinnedSyncRafRef = React.useRef<number | null>(null);
|
const pinnedSyncRafRef = React.useRef<number | null>(null);
|
||||||
const preferInstantPinRef = React.useRef(false);
|
const followModeRef = React.useRef<FollowMode>('none');
|
||||||
const autoFollowDuringWorkRef = React.useRef(false);
|
const autoScrollMarkerRef = React.useRef<AutoScrollMarker | null>(null);
|
||||||
const pendingSessionSwitchSnapRef = React.useRef(false);
|
|
||||||
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||||
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||||
const lastViewportAnchorWriteAtRef = React.useRef<number>(0);
|
const lastViewportAnchorWriteAtRef = React.useRef<number>(0);
|
||||||
|
|
||||||
const markProgrammaticScroll = React.useCallback(() => {
|
const markAutoScroll = React.useCallback((top: number) => {
|
||||||
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
autoScrollMarkerRef.current = {
|
||||||
|
top,
|
||||||
|
at: Date.now(),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isMarkedAutoScroll = React.useCallback((scrollTop: number) => {
|
||||||
|
const marker = autoScrollMarkerRef.current;
|
||||||
|
if (!marker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - marker.at > PROGRAMMATIC_SCROLL_SUPPRESS_MS) {
|
||||||
|
autoScrollMarkerRef.current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(scrollTop - marker.top) > 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getDistanceFromBottom = React.useCallback(() => {
|
const getDistanceFromBottom = React.useCallback(() => {
|
||||||
@@ -147,6 +172,10 @@ export const useChatScrollManager = ({
|
|||||||
setIsOverflowing((previous) => (previous === next ? previous : next));
|
setIsOverflowing((previous) => (previous === next ? previous : next));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setFollowMode = React.useCallback((next: FollowMode) => {
|
||||||
|
followModeRef.current = next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const shouldSkipLiveContentSync = React.useCallback(() => {
|
const shouldSkipLiveContentSync = React.useCallback(() => {
|
||||||
return !isPinnedRef.current && showScrollButtonRef.current && isOverflowingRef.current;
|
return !isPinnedRef.current && showScrollButtonRef.current && isOverflowingRef.current;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -156,32 +185,38 @@ export const useChatScrollManager = ({
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const bottom = container.scrollHeight - container.clientHeight;
|
const bottom = container.scrollHeight - container.clientHeight;
|
||||||
markProgrammaticScroll();
|
markAutoScroll(Math.max(0, bottom));
|
||||||
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
|
scrollEngine.scrollToPosition(Math.max(0, bottom), {
|
||||||
}, [markProgrammaticScroll, scrollEngine]);
|
...options,
|
||||||
|
persistFollow: Boolean(options?.followBottom && sessionIsWorking),
|
||||||
|
});
|
||||||
|
}, [markAutoScroll, scrollEngine, sessionIsWorking]);
|
||||||
|
|
||||||
const scrollPinnedToBottom = React.useCallback((distanceFromBottom: number) => {
|
const scrollPinnedToBottom = React.useCallback((distanceFromBottom: number) => {
|
||||||
if (sessionIsWorking) {
|
if (sessionIsWorking) {
|
||||||
if (autoFollowDuringWorkRef.current || scrollEngine.isFollowingBottom) {
|
if (followModeRef.current === 'smooth' || scrollEngine.isFollowingBottom) {
|
||||||
autoFollowDuringWorkRef.current = true;
|
|
||||||
scrollToBottomInternal({ followBottom: true });
|
scrollToBottomInternal({ followBottom: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preferInstantPinRef.current || distanceFromBottom > getAutoFollowSnapThreshold()) {
|
if (distanceFromBottom > getAutoFollowSnapThreshold()) {
|
||||||
autoFollowDuringWorkRef.current = false;
|
|
||||||
scrollToBottomInternal({ instant: true });
|
scrollToBottomInternal({ instant: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
autoFollowDuringWorkRef.current = true;
|
setFollowMode('smooth');
|
||||||
scrollToBottomInternal({ followBottom: true });
|
scrollToBottomInternal({ followBottom: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
autoFollowDuringWorkRef.current = false;
|
if (followModeRef.current === 'smooth' || scrollEngine.isFollowingBottom) {
|
||||||
|
scrollToBottomInternal({ followBottom: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFollowMode('none');
|
||||||
scrollToBottomInternal({ instant: true });
|
scrollToBottomInternal({ instant: true });
|
||||||
}, [getAutoFollowSnapThreshold, scrollEngine.isFollowingBottom, scrollToBottomInternal, sessionIsWorking]);
|
}, [getAutoFollowSnapThreshold, scrollEngine.isFollowingBottom, scrollToBottomInternal, sessionIsWorking, setFollowMode]);
|
||||||
|
|
||||||
const updateScrollButtonVisibility = React.useCallback(() => {
|
const updateScrollButtonVisibility = React.useCallback(() => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
@@ -207,49 +242,31 @@ export const useChatScrollManager = ({
|
|||||||
pinnedSyncRafRef.current = null;
|
pinnedSyncRafRef.current = null;
|
||||||
updateScrollButtonVisibility();
|
updateScrollButtonVisibility();
|
||||||
if (!isPinnedRef.current) {
|
if (!isPinnedRef.current) {
|
||||||
pendingSessionSwitchSnapRef.current = false;
|
setFollowMode('none');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distanceFromBottom = getDistanceFromBottom();
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
if (sessionIsWorking) {
|
if (sessionIsWorking) {
|
||||||
if (pendingSessionSwitchSnapRef.current && distanceFromBottom > 0.5) {
|
if (distanceFromBottom <= getAutoFollowThreshold()) {
|
||||||
autoFollowDuringWorkRef.current = false;
|
|
||||||
scrollToBottomInternal({ instant: true });
|
|
||||||
pendingSessionSwitchSnapRef.current = false;
|
|
||||||
preferInstantPinRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (distanceFromBottom <= 0.5) {
|
|
||||||
if (!pendingSessionSwitchSnapRef.current) {
|
|
||||||
preferInstantPinRef.current = false;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollPinnedToBottom(distanceFromBottom);
|
scrollPinnedToBottom(distanceFromBottom);
|
||||||
preferInstantPinRef.current = false;
|
|
||||||
pendingSessionSwitchSnapRef.current = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingSessionSwitchSnapRef.current = false;
|
|
||||||
|
|
||||||
if (distanceFromBottom <= getAutoFollowThreshold()) {
|
if (distanceFromBottom <= getAutoFollowThreshold()) {
|
||||||
preferInstantPinRef.current = false;
|
if (followModeRef.current !== 'smooth') {
|
||||||
return;
|
setFollowMode('none');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preferInstantPinRef.current) {
|
|
||||||
scrollToBottomInternal({ instant: true });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||||
scrollPinnedToBottom(distanceFromBottom);
|
scrollPinnedToBottom(distanceFromBottom);
|
||||||
}
|
}
|
||||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, scrollToBottomInternal, sessionIsWorking, updateScrollButtonVisibility]);
|
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, sessionIsWorking, setFollowMode, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
const schedulePinnedStateAndIndicators = React.useCallback(() => {
|
const schedulePinnedStateAndIndicators = React.useCallback(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -315,28 +332,30 @@ export const useChatScrollManager = ({
|
|||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
markProgrammaticScroll();
|
markAutoScroll(Math.max(0, position));
|
||||||
scrollEngine.scrollToPosition(Math.max(0, position), options);
|
scrollEngine.scrollToPosition(Math.max(0, position), options);
|
||||||
}, [markProgrammaticScroll, scrollEngine]);
|
}, [markAutoScroll, scrollEngine]);
|
||||||
|
|
||||||
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean }) => {
|
const prepareForBottomResume = React.useCallback(() => {
|
||||||
|
updatePinnedState(true);
|
||||||
|
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
|
||||||
|
setShowScrollButtonState(false);
|
||||||
|
}, [sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]);
|
||||||
|
|
||||||
|
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean; followBottom?: boolean }) => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Re-pin when explicitly scrolling to bottom
|
prepareForBottomResume();
|
||||||
updatePinnedState(true);
|
|
||||||
|
|
||||||
scrollToBottomInternal(options);
|
scrollToBottomInternal(options);
|
||||||
setShowScrollButtonState(false);
|
}, [prepareForBottomResume, scrollToBottomInternal]);
|
||||||
}, [scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]);
|
|
||||||
|
|
||||||
const releasePinnedScroll = React.useCallback(() => {
|
const releasePinnedScroll = React.useCallback(() => {
|
||||||
scrollEngine.cancelFollow();
|
scrollEngine.cancelFollow();
|
||||||
autoFollowDuringWorkRef.current = false;
|
setFollowMode('none');
|
||||||
preferInstantPinRef.current = false;
|
|
||||||
updatePinnedState(false);
|
updatePinnedState(false);
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
}, [schedulePinnedStateAndIndicators, scrollEngine, updatePinnedState]);
|
}, [schedulePinnedStateAndIndicators, scrollEngine, setFollowMode, updatePinnedState]);
|
||||||
|
|
||||||
const handleScrollEvent = React.useCallback((event?: Event) => {
|
const handleScrollEvent = React.useCallback((event?: Event) => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
@@ -344,9 +363,10 @@ export const useChatScrollManager = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const isProgrammatic = isMarkedAutoScroll(container.scrollTop);
|
||||||
const isProgrammatic = now < suppressUserScrollUntilRef.current;
|
if (isProgrammatic) {
|
||||||
const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS;
|
autoScrollMarkerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
scrollEngine.handleScroll();
|
scrollEngine.handleScroll();
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
@@ -355,9 +375,9 @@ export const useChatScrollManager = ({
|
|||||||
const currentScrollTop = container.scrollTop;
|
const currentScrollTop = container.scrollTop;
|
||||||
const scrollingUp = currentScrollTop < lastScrollTopRef.current;
|
const scrollingUp = currentScrollTop < lastScrollTopRef.current;
|
||||||
|
|
||||||
// Unpin requires strict user intent check
|
if (event?.isTrusted && !isProgrammatic) {
|
||||||
if (event?.isTrusted && !isProgrammatic && hasDirectIntent) {
|
|
||||||
if (scrollingUp && isPinnedRef.current) {
|
if (scrollingUp && isPinnedRef.current) {
|
||||||
|
setFollowMode('none');
|
||||||
updatePinnedState(false);
|
updatePinnedState(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +386,7 @@ export const useChatScrollManager = ({
|
|||||||
if (!isPinnedRef.current) {
|
if (!isPinnedRef.current) {
|
||||||
const distanceFromBottom = getDistanceFromBottom();
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
if (distanceFromBottom <= getPinThreshold()) {
|
if (distanceFromBottom <= getPinThreshold()) {
|
||||||
preferInstantPinRef.current = false;
|
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
|
||||||
updatePinnedState(true);
|
updatePinnedState(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,10 +401,13 @@ export const useChatScrollManager = ({
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
getDistanceFromBottom,
|
getDistanceFromBottom,
|
||||||
getPinThreshold,
|
getPinThreshold,
|
||||||
|
isMarkedAutoScroll,
|
||||||
queueViewportAnchor,
|
queueViewportAnchor,
|
||||||
schedulePinnedStateAndIndicators,
|
schedulePinnedStateAndIndicators,
|
||||||
scrollEngine,
|
scrollEngine,
|
||||||
|
setFollowMode,
|
||||||
sessionMessageCount,
|
sessionMessageCount,
|
||||||
|
sessionIsWorking,
|
||||||
updatePinnedState,
|
updatePinnedState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -406,28 +429,21 @@ export const useChatScrollManager = ({
|
|||||||
delta,
|
delta,
|
||||||
})) {
|
})) {
|
||||||
scrollEngine.cancelFollow();
|
scrollEngine.cancelFollow();
|
||||||
autoFollowDuringWorkRef.current = false;
|
setFollowMode('none');
|
||||||
updatePinnedState(false);
|
updatePinnedState(false);
|
||||||
}
|
}
|
||||||
}, [scrollEngine, updatePinnedState]);
|
}, [scrollEngine, setFollowMode, updatePinnedState]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const markDirectIntent = () => {
|
|
||||||
lastDirectScrollIntentAtRef.current = Date.now();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchStartIntent = (event: TouchEvent) => {
|
const handleTouchStartIntent = (event: TouchEvent) => {
|
||||||
markDirectIntent();
|
|
||||||
const touch = event.touches.item(0);
|
const touch = event.touches.item(0);
|
||||||
touchLastYRef.current = touch ? touch.clientY : null;
|
touchLastYRef.current = touch ? touch.clientY : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchMoveIntent = (event: TouchEvent) => {
|
const handleTouchMoveIntent = (event: TouchEvent) => {
|
||||||
markDirectIntent();
|
|
||||||
|
|
||||||
const touch = event.touches.item(0);
|
const touch = event.touches.item(0);
|
||||||
if (!touch) {
|
if (!touch) {
|
||||||
touchLastYRef.current = null;
|
touchLastYRef.current = null;
|
||||||
@@ -456,7 +472,7 @@ export const useChatScrollManager = ({
|
|||||||
delta: syntheticWheelDelta,
|
delta: syntheticWheelDelta,
|
||||||
})) {
|
})) {
|
||||||
scrollEngine.cancelFollow();
|
scrollEngine.cancelFollow();
|
||||||
autoFollowDuringWorkRef.current = false;
|
setFollowMode('none');
|
||||||
updatePinnedState(false);
|
updatePinnedState(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -471,7 +487,6 @@ export const useChatScrollManager = ({
|
|||||||
container.addEventListener('touchend', handleTouchEndIntent as EventListener, { passive: true });
|
container.addEventListener('touchend', handleTouchEndIntent as EventListener, { passive: true });
|
||||||
container.addEventListener('touchcancel', handleTouchEndIntent as EventListener, { passive: true });
|
container.addEventListener('touchcancel', handleTouchEndIntent as EventListener, { passive: true });
|
||||||
container.addEventListener('wheel', handleWheelIntent as EventListener, { passive: true });
|
container.addEventListener('wheel', handleWheelIntent as EventListener, { passive: true });
|
||||||
container.addEventListener('wheel', markDirectIntent as EventListener, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('scroll', handleScrollEvent as EventListener);
|
container.removeEventListener('scroll', handleScrollEvent as EventListener);
|
||||||
@@ -480,9 +495,8 @@ export const useChatScrollManager = ({
|
|||||||
container.removeEventListener('touchend', handleTouchEndIntent as EventListener);
|
container.removeEventListener('touchend', handleTouchEndIntent as EventListener);
|
||||||
container.removeEventListener('touchcancel', handleTouchEndIntent as EventListener);
|
container.removeEventListener('touchcancel', handleTouchEndIntent as EventListener);
|
||||||
container.removeEventListener('wheel', handleWheelIntent as EventListener);
|
container.removeEventListener('wheel', handleWheelIntent as EventListener);
|
||||||
container.removeEventListener('wheel', markDirectIntent as EventListener);
|
|
||||||
};
|
};
|
||||||
}, [handleScrollEvent, handleWheelIntent, scrollEngine, updatePinnedState]);
|
}, [handleScrollEvent, handleWheelIntent, scrollEngine, setFollowMode, updatePinnedState]);
|
||||||
|
|
||||||
// Session switch - always start pinned at bottom
|
// Session switch - always start pinned at bottom
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -494,28 +508,20 @@ export const useChatScrollManager = ({
|
|||||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||||
flushViewportAnchor();
|
flushViewportAnchor();
|
||||||
pendingViewportAnchorRef.current = null;
|
pendingViewportAnchorRef.current = null;
|
||||||
autoFollowDuringWorkRef.current = false;
|
|
||||||
pendingSessionSwitchSnapRef.current = true;
|
|
||||||
|
|
||||||
// Always start pinned at bottom on session switch
|
// Always start pinned at bottom on session switch
|
||||||
preferInstantPinRef.current = true;
|
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
|
||||||
updatePinnedState(true);
|
updatePinnedState(true);
|
||||||
setShowScrollButtonState(false);
|
setShowScrollButtonState(false);
|
||||||
|
}, [currentSessionId, flushViewportAnchor, sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]);
|
||||||
const container = scrollRef.current;
|
|
||||||
if (container) {
|
|
||||||
markProgrammaticScroll();
|
|
||||||
scrollToBottomInternal({ instant: true });
|
|
||||||
}
|
|
||||||
}, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]);
|
|
||||||
|
|
||||||
// Maintain pin-to-bottom when content changes
|
// Maintain pin-to-bottom when content changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!sessionIsWorking) {
|
if (!sessionIsWorking) {
|
||||||
autoFollowDuringWorkRef.current = false;
|
scrollEngine.cancelFollow();
|
||||||
pendingSessionSwitchSnapRef.current = false;
|
setFollowMode('none');
|
||||||
}
|
}
|
||||||
}, [sessionIsWorking]);
|
}, [scrollEngine, sessionIsWorking, setFollowMode]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isSyncing) {
|
if (isSyncing) {
|
||||||
@@ -541,14 +547,16 @@ export const useChatScrollManager = ({
|
|||||||
const scrollHeightChanged = nextScrollHeight !== lastScrollHeight;
|
const scrollHeightChanged = nextScrollHeight !== lastScrollHeight;
|
||||||
const clientHeightChanged = nextClientHeight !== lastClientHeight;
|
const clientHeightChanged = nextClientHeight !== lastClientHeight;
|
||||||
|
|
||||||
if (clientHeightChanged) {
|
if (scrollHeightChanged && isPinnedRef.current && sessionIsWorking) {
|
||||||
if (isPinnedRef.current && sessionIsWorking) {
|
setFollowMode('smooth');
|
||||||
lastScrollHeight = nextScrollHeight;
|
scrollToBottomInternal({ followBottom: true });
|
||||||
lastClientHeight = nextClientHeight;
|
lastScrollHeight = nextScrollHeight;
|
||||||
schedulePinnedStateAndIndicators();
|
lastClientHeight = nextClientHeight;
|
||||||
return;
|
updateScrollButtonVisibility();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientHeightChanged) {
|
||||||
const previousDistanceFromBottom = Math.max(
|
const previousDistanceFromBottom = Math.max(
|
||||||
0,
|
0,
|
||||||
lastScrollHeight - lastScrollTopRef.current - lastClientHeight,
|
lastScrollHeight - lastScrollTopRef.current - lastClientHeight,
|
||||||
@@ -561,7 +569,7 @@ export const useChatScrollManager = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (Math.abs(container.scrollTop - targetScrollTop) > 0.5) {
|
if (Math.abs(container.scrollTop - targetScrollTop) > 0.5) {
|
||||||
markProgrammaticScroll();
|
markAutoScroll(targetScrollTop);
|
||||||
container.scrollTop = targetScrollTop;
|
container.scrollTop = targetScrollTop;
|
||||||
lastScrollTopRef.current = targetScrollTop;
|
lastScrollTopRef.current = targetScrollTop;
|
||||||
}
|
}
|
||||||
@@ -593,7 +601,7 @@ export const useChatScrollManager = ({
|
|||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
};
|
};
|
||||||
}, [markProgrammaticScroll, schedulePinnedStateAndIndicators, sessionIsWorking, shouldSkipLiveContentSync, updateScrollButtonVisibility]);
|
}, [markAutoScroll, schedulePinnedStateAndIndicators, scrollToBottomInternal, sessionIsWorking, setFollowMode, shouldSkipLiveContentSync, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -784,6 +792,7 @@ export const useChatScrollManager = ({
|
|||||||
handleMessageContentChange,
|
handleMessageContentChange,
|
||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
showScrollButton,
|
showScrollButton,
|
||||||
|
prepareForBottomResume,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
scrollToPosition,
|
scrollToPosition,
|
||||||
releasePinnedScroll,
|
releasePinnedScroll,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type ScrollEngineOptions = {
|
|||||||
type ScrollOptions = {
|
type ScrollOptions = {
|
||||||
instant?: boolean;
|
instant?: boolean;
|
||||||
followBottom?: boolean; // Dynamically track bottom during streaming
|
followBottom?: boolean; // Dynamically track bottom during streaming
|
||||||
|
persistFollow?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ScrollEngineResult = {
|
type ScrollEngineResult = {
|
||||||
@@ -55,6 +56,7 @@ export const useScrollEngine = ({
|
|||||||
// Continuous follow-bottom rAF loop (for streaming)
|
// Continuous follow-bottom rAF loop (for streaming)
|
||||||
const followRafRef = React.useRef<number | null>(null);
|
const followRafRef = React.useRef<number | null>(null);
|
||||||
const followActiveRef = React.useRef(false);
|
const followActiveRef = React.useRef(false);
|
||||||
|
const followPersistRef = React.useRef(false);
|
||||||
|
|
||||||
const cancelSpring = React.useCallback(() => {
|
const cancelSpring = React.useCallback(() => {
|
||||||
if (scrollAnimRef.current) {
|
if (scrollAnimRef.current) {
|
||||||
@@ -69,6 +71,7 @@ export const useScrollEngine = ({
|
|||||||
followRafRef.current = null;
|
followRafRef.current = null;
|
||||||
}
|
}
|
||||||
followActiveRef.current = false;
|
followActiveRef.current = false;
|
||||||
|
followPersistRef.current = false;
|
||||||
setIsFollowingBottom(false);
|
setIsFollowingBottom(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -78,7 +81,8 @@ export const useScrollEngine = ({
|
|||||||
}, [cancelSpring, cancelFollow]);
|
}, [cancelSpring, cancelFollow]);
|
||||||
|
|
||||||
// Continuous lerp loop that chases scrollHeight - clientHeight.
|
// Continuous lerp loop that chases scrollHeight - clientHeight.
|
||||||
const startFollowLoop = React.useCallback(() => {
|
const startFollowLoop = React.useCallback((persist = false) => {
|
||||||
|
followPersistRef.current = persist || followPersistRef.current;
|
||||||
if (followActiveRef.current) return; // already running
|
if (followActiveRef.current) return; // already running
|
||||||
followActiveRef.current = true;
|
followActiveRef.current = true;
|
||||||
setIsFollowingBottom(true);
|
setIsFollowingBottom(true);
|
||||||
@@ -99,6 +103,11 @@ export const useScrollEngine = ({
|
|||||||
|
|
||||||
if (Math.abs(delta) <= SNAP_EPSILON) {
|
if (Math.abs(delta) <= SNAP_EPSILON) {
|
||||||
container.scrollTop = target;
|
container.scrollTop = target;
|
||||||
|
if (followPersistRef.current) {
|
||||||
|
stableFrames = 0;
|
||||||
|
followRafRef.current = window.requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
stableFrames += 1;
|
stableFrames += 1;
|
||||||
if (stableFrames >= FOLLOW_STABLE_FRAME_LIMIT) {
|
if (stableFrames >= FOLLOW_STABLE_FRAME_LIMIT) {
|
||||||
followActiveRef.current = false;
|
followActiveRef.current = false;
|
||||||
@@ -126,6 +135,7 @@ export const useScrollEngine = ({
|
|||||||
const target = Math.max(0, position);
|
const target = Math.max(0, position);
|
||||||
const preferInstant = options?.instant ?? false;
|
const preferInstant = options?.instant ?? false;
|
||||||
const followBottom = options?.followBottom ?? false;
|
const followBottom = options?.followBottom ?? false;
|
||||||
|
const persistFollow = options?.persistFollow ?? false;
|
||||||
|
|
||||||
manualOverrideRef.current = false;
|
manualOverrideRef.current = false;
|
||||||
|
|
||||||
@@ -134,6 +144,10 @@ export const useScrollEngine = ({
|
|||||||
cancelAll();
|
cancelAll();
|
||||||
container.scrollTop = target;
|
container.scrollTop = target;
|
||||||
|
|
||||||
|
if (followBottom && typeof window !== 'undefined') {
|
||||||
|
startFollowLoop(persistFollow);
|
||||||
|
}
|
||||||
|
|
||||||
const atTop = target <= 1;
|
const atTop = target <= 1;
|
||||||
if (atTopRef.current !== atTop) {
|
if (atTopRef.current !== atTop) {
|
||||||
atTopRef.current = atTop;
|
atTopRef.current = atTop;
|
||||||
@@ -145,7 +159,7 @@ export const useScrollEngine = ({
|
|||||||
// Follow-bottom mode: start the continuous lerp loop
|
// Follow-bottom mode: start the continuous lerp loop
|
||||||
if (followBottom) {
|
if (followBottom) {
|
||||||
cancelSpring();
|
cancelSpring();
|
||||||
startFollowLoop();
|
startFollowLoop(persistFollow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type UseTimelineStagingResult<T> = {
|
|||||||
stagedMessages: T[]
|
stagedMessages: T[]
|
||||||
/** Whether staging is still in progress */
|
/** Whether staging is still in progress */
|
||||||
isStaging: boolean
|
isStaging: boolean
|
||||||
|
/** Force the current session timeline to render fully now */
|
||||||
|
completeNow: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: StageConfig = { init: 1, batch: 3 }
|
const DEFAULT_CONFIG: StageConfig = { init: 1, batch: 3 }
|
||||||
@@ -45,6 +47,31 @@ export function useTimelineStaging<T>(
|
|||||||
const activeSession = useRef("")
|
const activeSession = useRef("")
|
||||||
const frameRef = useRef<number | null>(null)
|
const frameRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const completeNow = () => {
|
||||||
|
if (!sessionKey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current)
|
||||||
|
frameRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSession.current = ""
|
||||||
|
completedSessions.current.add(sessionKey)
|
||||||
|
|
||||||
|
const total = messages.length
|
||||||
|
let changed = false
|
||||||
|
setStagedCount((previous) => {
|
||||||
|
if (previous === total) {
|
||||||
|
return previous
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return total
|
||||||
|
})
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Cancel any pending animation frame
|
// Cancel any pending animation frame
|
||||||
if (frameRef.current !== null) {
|
if (frameRef.current !== null) {
|
||||||
@@ -110,5 +137,5 @@ export function useTimelineStaging<T>(
|
|||||||
const isStaging = activeSession.current === sessionKey &&
|
const isStaging = activeSession.current === sessionKey &&
|
||||||
!completedSessions.current.has(sessionKey)
|
!completedSessions.current.has(sessionKey)
|
||||||
|
|
||||||
return { stagedMessages, isStaging }
|
return { stagedMessages, isStaging, completeNow }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1093,23 +1093,6 @@ html:not(.dark) .chat-scroll {
|
|||||||
background-color: var(--shiki-dark-bg) !important;
|
background-color: var(--shiki-dark-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix list styling - override Tailwind reset */
|
|
||||||
.markdown-content ul {
|
|
||||||
list-style-type: disc;
|
|
||||||
list-style-position: outside;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-content ol {
|
|
||||||
list-style-type: decimal;
|
|
||||||
list-style-position: outside;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-content li {
|
|
||||||
display: list-item;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduce code block header height */
|
/* Reduce code block header height */
|
||||||
[data-markdown="code-block-header"] {
|
[data-markdown="code-block-header"] {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -168,6 +168,36 @@
|
|||||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, 'Liberation Mono', Menlo, monospace) !important;
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, 'Liberation Mono', Menlo, monospace) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Restore default list styling inside markdown content - override Tailwind preflight */
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
padding-left: 2em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul {
|
||||||
|
list-style-type: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul > li {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul > li::before {
|
||||||
|
content: "–";
|
||||||
|
position: absolute;
|
||||||
|
left: -1.25em;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ol {
|
||||||
|
list-style-type: decimal !important;
|
||||||
|
list-style-position: outside !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
display: list-item !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Remove focus rings globally (except inputs/textareas/dropdowns/selects) */
|
/* Remove focus rings globally (except inputs/textareas/dropdowns/selects) */
|
||||||
*:focus:not(input):not(textarea):not([data-slot="dropdown-menu-content"]):not([data-slot="dropdown-menu-sub-content"]):not([data-slot="select-content"]) {
|
*:focus:not(input):not(textarea):not([data-slot="dropdown-menu-content"]):not([data-slot="dropdown-menu-sub-content"]):not([data-slot="select-content"]) {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user