fix: restore smooth chat auto-scroll and live compaction updates
- Keep assistant auto-scroll smooth during active responses without periodic snaps - Restore live `/compact` rendering and streamed compaction summaries without refresh - Preserve global event handling while routing session and message updates efficiently
This commit is contained in:
@@ -494,7 +494,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
} = useChatScrollManager({
|
} = useChatScrollManager({
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
sessionMessageCount,
|
sessionMessageCount,
|
||||||
streamingMessageId,
|
sessionIsWorking,
|
||||||
sessionMemoryState: sessionMemoryStateMap,
|
sessionMemoryState: sessionMemoryStateMap,
|
||||||
updateViewportAnchor,
|
updateViewportAnchor,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface UseChatScrollManagerOptions {
|
|||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
sessionMessageCount: number;
|
sessionMessageCount: number;
|
||||||
sessionPermissions: unknown[];
|
sessionPermissions: unknown[];
|
||||||
streamingMessageId: string | null;
|
sessionIsWorking: boolean;
|
||||||
sessionMemoryState: Map<string, SessionMemoryState>;
|
sessionMemoryState: Map<string, SessionMemoryState>;
|
||||||
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
||||||
isSyncing: boolean;
|
isSyncing: boolean;
|
||||||
@@ -67,6 +67,7 @@ const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150;
|
|||||||
export const useChatScrollManager = ({
|
export const useChatScrollManager = ({
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
sessionMessageCount,
|
sessionMessageCount,
|
||||||
|
sessionIsWorking,
|
||||||
updateViewportAnchor,
|
updateViewportAnchor,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -88,6 +89,16 @@ export const useChatScrollManager = ({
|
|||||||
return getPinThreshold();
|
return getPinThreshold();
|
||||||
}, [getPinThreshold]);
|
}, [getPinThreshold]);
|
||||||
|
|
||||||
|
const getAutoFollowSnapThreshold = React.useCallback(() => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container || container.clientHeight <= 0) {
|
||||||
|
return 96;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = container.clientHeight * 0.2;
|
||||||
|
return Math.max(72, Math.min(192, raw));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||||
const [isPinned, setIsPinned] = React.useState(true);
|
const [isPinned, setIsPinned] = React.useState(true);
|
||||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||||
@@ -102,6 +113,7 @@ export const useChatScrollManager = ({
|
|||||||
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 preferInstantPinRef = React.useRef(false);
|
||||||
|
const autoFollowDuringWorkRef = 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);
|
||||||
@@ -147,13 +159,28 @@ export const useChatScrollManager = ({
|
|||||||
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
|
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
|
||||||
}, [markProgrammaticScroll, scrollEngine]);
|
}, [markProgrammaticScroll, scrollEngine]);
|
||||||
|
|
||||||
const scrollPinnedToBottom = React.useCallback(() => {
|
const scrollPinnedToBottom = React.useCallback((distanceFromBottom: number) => {
|
||||||
// Auto-follow should not animate across an entire new turn.
|
if (sessionIsWorking) {
|
||||||
// Keep explicit user-triggered scrolls animated, but snap pinned
|
if (autoFollowDuringWorkRef.current || scrollEngine.isFollowingBottom) {
|
||||||
// updates so a new user/assistant message does not visibly travel
|
autoFollowDuringWorkRef.current = true;
|
||||||
// from the previous sticky header down to the tail.
|
scrollToBottomInternal({ followBottom: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferInstantPinRef.current || distanceFromBottom > getAutoFollowSnapThreshold()) {
|
||||||
|
autoFollowDuringWorkRef.current = false;
|
||||||
|
scrollToBottomInternal({ instant: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoFollowDuringWorkRef.current = true;
|
||||||
|
scrollToBottomInternal({ followBottom: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoFollowDuringWorkRef.current = false;
|
||||||
scrollToBottomInternal({ instant: true });
|
scrollToBottomInternal({ instant: true });
|
||||||
}, [scrollToBottomInternal]);
|
}, [getAutoFollowSnapThreshold, scrollEngine.isFollowingBottom, scrollToBottomInternal, sessionIsWorking]);
|
||||||
|
|
||||||
const updateScrollButtonVisibility = React.useCallback(() => {
|
const updateScrollButtonVisibility = React.useCallback(() => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
@@ -183,6 +210,17 @@ export const useChatScrollManager = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const distanceFromBottom = getDistanceFromBottom();
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
|
if (sessionIsWorking) {
|
||||||
|
if (distanceFromBottom <= 0.5) {
|
||||||
|
preferInstantPinRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollPinnedToBottom(distanceFromBottom);
|
||||||
|
preferInstantPinRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (distanceFromBottom <= getAutoFollowThreshold()) {
|
if (distanceFromBottom <= getAutoFollowThreshold()) {
|
||||||
preferInstantPinRef.current = false;
|
preferInstantPinRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -194,9 +232,9 @@ export const useChatScrollManager = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||||
scrollPinnedToBottom();
|
scrollPinnedToBottom(distanceFromBottom);
|
||||||
}
|
}
|
||||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, scrollToBottomInternal, updateScrollButtonVisibility]);
|
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, scrollToBottomInternal, sessionIsWorking, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
const schedulePinnedStateAndIndicators = React.useCallback(() => {
|
const schedulePinnedStateAndIndicators = React.useCallback(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -279,6 +317,7 @@ export const useChatScrollManager = ({
|
|||||||
|
|
||||||
const releasePinnedScroll = React.useCallback(() => {
|
const releasePinnedScroll = React.useCallback(() => {
|
||||||
scrollEngine.cancelFollow();
|
scrollEngine.cancelFollow();
|
||||||
|
autoFollowDuringWorkRef.current = false;
|
||||||
preferInstantPinRef.current = false;
|
preferInstantPinRef.current = false;
|
||||||
updatePinnedState(false);
|
updatePinnedState(false);
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
@@ -352,6 +391,7 @@ export const useChatScrollManager = ({
|
|||||||
delta,
|
delta,
|
||||||
})) {
|
})) {
|
||||||
scrollEngine.cancelFollow();
|
scrollEngine.cancelFollow();
|
||||||
|
autoFollowDuringWorkRef.current = false;
|
||||||
updatePinnedState(false);
|
updatePinnedState(false);
|
||||||
}
|
}
|
||||||
}, [scrollEngine, updatePinnedState]);
|
}, [scrollEngine, updatePinnedState]);
|
||||||
@@ -401,6 +441,7 @@ export const useChatScrollManager = ({
|
|||||||
delta: syntheticWheelDelta,
|
delta: syntheticWheelDelta,
|
||||||
})) {
|
})) {
|
||||||
scrollEngine.cancelFollow();
|
scrollEngine.cancelFollow();
|
||||||
|
autoFollowDuringWorkRef.current = false;
|
||||||
updatePinnedState(false);
|
updatePinnedState(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -438,6 +479,7 @@ export const useChatScrollManager = ({
|
|||||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||||
flushViewportAnchor();
|
flushViewportAnchor();
|
||||||
pendingViewportAnchorRef.current = null;
|
pendingViewportAnchorRef.current = null;
|
||||||
|
autoFollowDuringWorkRef.current = false;
|
||||||
|
|
||||||
// Always start pinned at bottom on session switch
|
// Always start pinned at bottom on session switch
|
||||||
preferInstantPinRef.current = true;
|
preferInstantPinRef.current = true;
|
||||||
@@ -452,6 +494,12 @@ export const useChatScrollManager = ({
|
|||||||
}, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]);
|
}, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]);
|
||||||
|
|
||||||
// Maintain pin-to-bottom when content changes
|
// Maintain pin-to-bottom when content changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!sessionIsWorking) {
|
||||||
|
autoFollowDuringWorkRef.current = false;
|
||||||
|
}
|
||||||
|
}, [sessionIsWorking]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isSyncing) {
|
if (isSyncing) {
|
||||||
return;
|
return;
|
||||||
@@ -477,6 +525,13 @@ export const useChatScrollManager = ({
|
|||||||
const clientHeightChanged = nextClientHeight !== lastClientHeight;
|
const clientHeightChanged = nextClientHeight !== lastClientHeight;
|
||||||
|
|
||||||
if (clientHeightChanged) {
|
if (clientHeightChanged) {
|
||||||
|
if (isPinnedRef.current && sessionIsWorking) {
|
||||||
|
lastScrollHeight = nextScrollHeight;
|
||||||
|
lastClientHeight = nextClientHeight;
|
||||||
|
schedulePinnedStateAndIndicators();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const previousDistanceFromBottom = Math.max(
|
const previousDistanceFromBottom = Math.max(
|
||||||
0,
|
0,
|
||||||
lastScrollHeight - lastScrollTopRef.current - lastClientHeight,
|
lastScrollHeight - lastScrollTopRef.current - lastClientHeight,
|
||||||
@@ -521,7 +576,7 @@ export const useChatScrollManager = ({
|
|||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
};
|
};
|
||||||
}, [markProgrammaticScroll, schedulePinnedStateAndIndicators, shouldSkipLiveContentSync, updateScrollButtonVisibility]);
|
}, [markProgrammaticScroll, schedulePinnedStateAndIndicators, sessionIsWorking, shouldSkipLiveContentSync, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
|||||||
@@ -892,7 +892,46 @@ html:not(.dark) .chat-scroll {
|
|||||||
/* Reasoning markdown renders at meta size, dimmed. */
|
/* Reasoning markdown renders at meta size, dimmed. */
|
||||||
.markdown-content.markdown-reasoning {
|
.markdown-content.markdown-reasoning {
|
||||||
font-size: var(--text-meta);
|
font-size: var(--text-meta);
|
||||||
color: var(--muted-foreground);
|
color: var(--surface-muted-foreground);
|
||||||
|
--markdown-heading1: inherit;
|
||||||
|
--markdown-heading2: inherit;
|
||||||
|
--markdown-heading3: inherit;
|
||||||
|
--markdown-heading4: inherit;
|
||||||
|
--markdown-link: inherit;
|
||||||
|
--markdown-link-hover: var(--surface-foreground);
|
||||||
|
--markdown-blockquote: inherit;
|
||||||
|
--markdown-bold: inherit;
|
||||||
|
--markdown-italic: inherit;
|
||||||
|
--markdown-strikethrough: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content.markdown-reasoning p,
|
||||||
|
.markdown-content.markdown-reasoning li,
|
||||||
|
.markdown-content.markdown-reasoning td,
|
||||||
|
.markdown-content.markdown-reasoning th,
|
||||||
|
.markdown-content.markdown-reasoning h1,
|
||||||
|
.markdown-content.markdown-reasoning h2,
|
||||||
|
.markdown-content.markdown-reasoning h3,
|
||||||
|
.markdown-content.markdown-reasoning h4,
|
||||||
|
.markdown-content.markdown-reasoning h5,
|
||||||
|
.markdown-content.markdown-reasoning h6,
|
||||||
|
.markdown-content.markdown-reasoning strong,
|
||||||
|
.markdown-content.markdown-reasoning b,
|
||||||
|
.markdown-content.markdown-reasoning em,
|
||||||
|
.markdown-content.markdown-reasoning del,
|
||||||
|
.markdown-content.markdown-reasoning [data-markdown="strong"],
|
||||||
|
.markdown-content.markdown-reasoning [data-markdown="bold"] {
|
||||||
|
color: var(--surface-muted-foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content.markdown-reasoning a,
|
||||||
|
.markdown-content.markdown-reasoning [data-openchamber-file-link="true"] {
|
||||||
|
color: var(--surface-muted-foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content.markdown-reasoning a:hover,
|
||||||
|
.markdown-content.markdown-reasoning [data-openchamber-file-link="true"]:hover {
|
||||||
|
color: var(--surface-foreground) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content.markdown-reasoning strong,
|
.markdown-content.markdown-reasoning strong,
|
||||||
|
|||||||
@@ -446,7 +446,11 @@ const resolveDirectoryFromRoutingIndex = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((!normalizedDirectory || normalizedDirectory === "global") && childStores.children.size === 1) {
|
if (
|
||||||
|
(sessionID || messageID)
|
||||||
|
&& (!normalizedDirectory || normalizedDirectory === "global")
|
||||||
|
&& childStores.children.size === 1
|
||||||
|
) {
|
||||||
const onlyDirectory = childStores.children.keys().next().value
|
const onlyDirectory = childStores.children.keys().next().value
|
||||||
if (typeof onlyDirectory === "string" && onlyDirectory.length > 0) {
|
if (typeof onlyDirectory === "string" && onlyDirectory.length > 0) {
|
||||||
return onlyDirectory
|
return onlyDirectory
|
||||||
|
|||||||
Reference in New Issue
Block a user